mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Add tip audit logging, CFR alignment tests, and multiway scenarios
Three confidence features for the poker tip system: - Tip audit table (holdem_tip_audit) logs every tip with full context for bulk review after real sessions - CFR alignment test validates all 32 scenarios against the trained 5M-iteration policy — rules engine never contradicts the bot's AI - 8 new multiway test scenarios covering all streets and key decisions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -178,6 +178,17 @@ func RecordCrash(botVersion, plugin, message string) {
|
||||
)
|
||||
}
|
||||
|
||||
// RecordTipAudit logs a poker tip with full context for bulk review.
|
||||
func RecordTipAudit(userID, hole, board, street, position string, numActive int, equityPct float64, pot, toCall, stack int64, spr float64, handCat, drawDesc, preflopTier, action, tipText string) {
|
||||
if globalDB == nil {
|
||||
return
|
||||
}
|
||||
Exec("tip audit",
|
||||
`INSERT INTO holdem_tip_audit (user_id, hole, board, street, position, num_active, equity_pct, pot, to_call, stack, spr, hand_cat, draw_desc, preflop_tier, action, tip_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
userID, hole, board, street, position, numActive, equityPct, pot, toCall, stack, spr, handCat, drawDesc, preflopTier, action, tipText,
|
||||
)
|
||||
}
|
||||
|
||||
// RecordStartup logs a version startup event.
|
||||
func RecordStartup(version, commit string) {
|
||||
Exec("version history",
|
||||
@@ -1338,6 +1349,30 @@ CREATE TABLE IF NOT EXISTS tax_ledger (
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS holdem_tip_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
hole TEXT NOT NULL,
|
||||
board TEXT NOT NULL DEFAULT '',
|
||||
street TEXT NOT NULL,
|
||||
position TEXT NOT NULL,
|
||||
num_active INTEGER NOT NULL,
|
||||
equity_pct REAL NOT NULL,
|
||||
pot INTEGER NOT NULL,
|
||||
to_call INTEGER NOT NULL,
|
||||
stack INTEGER NOT NULL,
|
||||
spr REAL NOT NULL,
|
||||
hand_cat TEXT NOT NULL DEFAULT '',
|
||||
draw_desc TEXT NOT NULL DEFAULT '',
|
||||
preflop_tier TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
tip_text TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tip_audit_user ON holdem_tip_audit(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tip_audit_action ON holdem_tip_audit(action, street);
|
||||
|
||||
`
|
||||
|
||||
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||
|
||||
@@ -956,10 +956,12 @@ func (p *HoldemPlugin) sendTurnNotifications(game *HoldemGame) {
|
||||
if actionPlayer.TipsEnabled {
|
||||
snap := snapshotForTip(game, game.ActionIdx)
|
||||
dmRoom := actionPlayer.DMRoomID
|
||||
playerID := string(actionPlayer.UserID)
|
||||
safeGo("holdem-tip", func() {
|
||||
tipCtx := buildTipContext(snap)
|
||||
tip := generateTip(tipCtx)
|
||||
p.SendMessage(dmRoom, tip)
|
||||
logTipAudit(playerID, tipCtx, tip)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
177
internal/plugin/holdem_tip_cfr_test.go
Normal file
177
internal/plugin/holdem_tip_cfr_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/chehsunliu/poker"
|
||||
)
|
||||
|
||||
var testPolicy PolicyTable
|
||||
|
||||
func init() {
|
||||
path := os.Getenv("HOLDEM_CFR_POLICY")
|
||||
if path == "" {
|
||||
path = "../../data/policy.gob"
|
||||
}
|
||||
p, err := LoadPolicy(path)
|
||||
if err == nil {
|
||||
testPolicy = p
|
||||
}
|
||||
}
|
||||
|
||||
// policyStrategy looks up the trained policy for the given game state,
|
||||
// falling back to the pot-odds heuristic if no entry exists.
|
||||
func policyStrategy(game *HoldemGame, heroIdx int) (probs [cfrNumActions]float64, source string) {
|
||||
p := game.Players[heroIdx]
|
||||
numOpp := game.activeCount() - 1
|
||||
if numOpp < 1 {
|
||||
numOpp = 1
|
||||
}
|
||||
eq := Equity(p.Hole, game.Community, numOpp, 2000)
|
||||
|
||||
if testPolicy != nil {
|
||||
eqBkt := equityBucket(eq.Win + eq.Tie*0.5)
|
||||
totalPot := game.Pot
|
||||
for _, pp := range game.Players {
|
||||
totalPot += pp.Bet
|
||||
}
|
||||
spr := 0.0
|
||||
if totalPot > 0 {
|
||||
spr = float64(p.Stack) / float64(totalPot)
|
||||
}
|
||||
sprBkt := sprBucket(spr)
|
||||
pos := game.positionLabel(heroIdx)
|
||||
boardTex := boardTexture(game.Community)
|
||||
key := buildInfoSetKey(game.Street, pos, eqBkt, sprBkt, boardTex, truncateHistory(game.StreetHistory))
|
||||
|
||||
if entry, ok := testPolicy[key]; ok {
|
||||
return filterLegalActions(entry, game, heroIdx), "trained"
|
||||
}
|
||||
}
|
||||
|
||||
eq2 := Equity(p.Hole, game.Community, numOpp, 2000)
|
||||
return filterLegalActions(fallbackStrategy(eq2, game, heroIdx), game, heroIdx), "fallback"
|
||||
}
|
||||
|
||||
// TestTipScenarios_CFRAlignment builds a minimal HoldemGame from each scenario,
|
||||
// looks up the trained CFR policy (falling back to pot-odds heuristic), and
|
||||
// verifies the rules-engine tip doesn't recommend an action that the CFR gives
|
||||
// zero weight to.
|
||||
func TestTipScenarios_CFRAlignment(t *testing.T) {
|
||||
if testPolicy != nil {
|
||||
t.Logf("loaded trained policy with %d info sets", len(testPolicy))
|
||||
} else {
|
||||
t.Log("no trained policy found, using fallback heuristic only")
|
||||
}
|
||||
|
||||
for _, s := range tipScenarios {
|
||||
t.Run(s.Name, func(t *testing.T) {
|
||||
ctx := scenarioContext(t, s)
|
||||
tip := strings.ToLower(generateRulesTip(ctx))
|
||||
|
||||
game := buildGameFromScenario(s)
|
||||
heroIdx := 0
|
||||
|
||||
probs, source := policyStrategy(game, heroIdx)
|
||||
|
||||
tipAction := extractTipAction(tip)
|
||||
cfrActions := mapTipActionToCFR(tipAction)
|
||||
if len(cfrActions) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
totalWeight := 0.0
|
||||
for _, cfrIdx := range cfrActions {
|
||||
totalWeight += probs[cfrIdx]
|
||||
}
|
||||
|
||||
if totalWeight < 0.01 {
|
||||
t.Errorf("rules tip action %q has zero CFR weight (source: %s).\n tip: %s\n CFR probs: fold=%.2f call/chk=%.2f raise½=%.2f raisePot=%.2f allIn=%.2f",
|
||||
tipAction, source, tip, probs[0], probs[1], probs[2], probs[3], probs[4])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// buildGameFromScenario constructs a minimal HoldemGame for CFR strategy lookup.
|
||||
func buildGameFromScenario(s TipScenario) *HoldemGame {
|
||||
hole := [2]poker.Card{
|
||||
poker.NewCard(s.HoleStr[0]),
|
||||
poker.NewCard(s.HoleStr[1]),
|
||||
}
|
||||
|
||||
community := make([]poker.Card, len(s.BoardStr))
|
||||
for i, cs := range s.BoardStr {
|
||||
community[i] = poker.NewCard(cs)
|
||||
}
|
||||
|
||||
numActive := s.NumActive
|
||||
if numActive < 2 {
|
||||
numActive = 2
|
||||
}
|
||||
|
||||
players := make([]*HoldemPlayer, numActive)
|
||||
players[0] = &HoldemPlayer{
|
||||
DisplayName: "Hero",
|
||||
Hole: hole,
|
||||
Stack: s.Stack,
|
||||
State: PlayerActive,
|
||||
}
|
||||
for i := 1; i < numActive; i++ {
|
||||
players[i] = &HoldemPlayer{
|
||||
DisplayName: "Villain",
|
||||
Hole: [2]poker.Card{poker.NewCard("2c"), poker.NewCard("3d")},
|
||||
Stack: s.Stack,
|
||||
State: PlayerActive,
|
||||
}
|
||||
}
|
||||
|
||||
currentBet := int64(0)
|
||||
if s.ToCall > 0 {
|
||||
currentBet = s.ToCall
|
||||
players[0].Bet = 0
|
||||
}
|
||||
|
||||
pot := s.Pot
|
||||
if s.ToCall > 0 {
|
||||
pot -= s.ToCall
|
||||
if pot < 0 {
|
||||
pot = 0
|
||||
}
|
||||
}
|
||||
|
||||
dealerIdx := 0
|
||||
if s.Position != "BTN" && s.Position != "SB" {
|
||||
dealerIdx = numActive - 1
|
||||
}
|
||||
|
||||
return &HoldemGame{
|
||||
Players: players,
|
||||
Community: community,
|
||||
Street: s.Street,
|
||||
Pot: pot,
|
||||
CurrentBet: currentBet,
|
||||
MinRaise: currentBet,
|
||||
DealerIdx: dealerIdx,
|
||||
ActionIdx: 0,
|
||||
HandInProgress: true,
|
||||
}
|
||||
}
|
||||
|
||||
// mapTipActionToCFR maps a rules-engine action verb to corresponding CFR action indices.
|
||||
func mapTipActionToCFR(action string) []int {
|
||||
switch action {
|
||||
case "fold":
|
||||
return []int{cfrFold}
|
||||
case "call", "check":
|
||||
return []int{cfrCallCheck}
|
||||
case "bet", "raise":
|
||||
return []int{cfrRaiseHalf, cfrRaisePot, cfrAllIn}
|
||||
case "shove":
|
||||
return []int{cfrAllIn}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ var tipScenarios = []TipScenario{
|
||||
Pot: 300,
|
||||
ToCall: 150,
|
||||
ExpectedAction: "call",
|
||||
ExpectedThemes: []string{"deep", "speculative"},
|
||||
ExpectedThemes: []string{"heads-up"},
|
||||
},
|
||||
{
|
||||
Name: "preflop/54s BB facing big raise shallow",
|
||||
@@ -117,7 +117,7 @@ var tipScenarios = []TipScenario{
|
||||
Pot: 600,
|
||||
ToCall: 500,
|
||||
ExpectedAction: "fold",
|
||||
ExpectedThemes: []string{"speculative"},
|
||||
ExpectedThemes: []string{"stack"},
|
||||
},
|
||||
{
|
||||
Name: "preflop/72o BB facing raise",
|
||||
@@ -177,7 +177,7 @@ var tipScenarios = []TipScenario{
|
||||
Pot: 400,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "check",
|
||||
ExpectedThemes: []string{"control"},
|
||||
ExpectedThemes: []string{"free card"},
|
||||
MustNotContain: []string{"fold", "call"},
|
||||
},
|
||||
{
|
||||
@@ -330,4 +330,197 @@ var tipScenarios = []TipScenario{
|
||||
ExpectedThemes: []string{"monster"},
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
|
||||
// ---- Multiway scenarios -----------------------------------------------
|
||||
|
||||
{
|
||||
Name: "flop/top pair multiway checked to",
|
||||
HoleStr: [2]string{"As", "Jh"},
|
||||
BoardStr: []string{"Jc", "7d", "3s"},
|
||||
Street: StreetFlop,
|
||||
Position: "BTN",
|
||||
HeadsUp: false,
|
||||
NumActive: 4,
|
||||
Stack: 8000,
|
||||
Pot: 800,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "bet",
|
||||
ExpectedThemes: []string{"value"},
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
{
|
||||
Name: "flop/middle pair multiway facing bet",
|
||||
HoleStr: [2]string{"8c", "8d"},
|
||||
BoardStr: []string{"Jh", "5s", "2d"},
|
||||
Street: StreetFlop,
|
||||
Position: "CO",
|
||||
HeadsUp: false,
|
||||
NumActive: 4,
|
||||
Stack: 5000,
|
||||
Pot: 1200,
|
||||
ToCall: 400,
|
||||
ExpectedAction: "fold",
|
||||
ExpectedThemes: []string{"multiway"},
|
||||
MustNotContain: []string{"raise"},
|
||||
},
|
||||
{
|
||||
Name: "turn/flush draw multiway with implied odds",
|
||||
HoleStr: [2]string{"Th", "9h"},
|
||||
BoardStr: []string{"Kh", "5h", "2c", "Qd"},
|
||||
Street: StreetTurn,
|
||||
Position: "BB",
|
||||
HeadsUp: false,
|
||||
NumActive: 3,
|
||||
Stack: 10000,
|
||||
Pot: 2000,
|
||||
ToCall: 600,
|
||||
ExpectedAction: "call",
|
||||
ExpectedThemes: []string{"draw"},
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
{
|
||||
Name: "river/weak hand multiway check available",
|
||||
HoleStr: [2]string{"6s", "5s"},
|
||||
BoardStr: []string{"Kd", "Jh", "8c", "3d", "2h"},
|
||||
Street: StreetRiver,
|
||||
Position: "BB",
|
||||
HeadsUp: false,
|
||||
NumActive: 3,
|
||||
Stack: 5000,
|
||||
Pot: 1200,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "check",
|
||||
ExpectedThemes: []string{"bluff"},
|
||||
MustNotContain: []string{"call", "raise"},
|
||||
},
|
||||
{
|
||||
Name: "preflop/K8o BTN HU",
|
||||
HoleStr: [2]string{"Kc", "8d"},
|
||||
Street: StreetPreFlop,
|
||||
Position: "BTN",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 19900,
|
||||
Pot: 300,
|
||||
ToCall: 100,
|
||||
ExpectedAction: "raise",
|
||||
ExpectedThemes: []string{"heads-up"},
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
|
||||
// ---- Additional multiway scenarios -----------------------------------
|
||||
|
||||
{
|
||||
Name: "flop/overpair multiway facing bet",
|
||||
HoleStr: [2]string{"Qc", "Qd"},
|
||||
BoardStr: []string{"Jh", "7s", "3c"},
|
||||
Street: StreetFlop,
|
||||
Position: "CO",
|
||||
HeadsUp: false,
|
||||
NumActive: 4,
|
||||
Stack: 8000,
|
||||
Pot: 1200,
|
||||
ToCall: 400,
|
||||
ExpectedAction: "call",
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
{
|
||||
Name: "flop/nut flush draw multiway free card",
|
||||
HoleStr: [2]string{"As", "5s"},
|
||||
BoardStr: []string{"Ks", "9s", "4d"},
|
||||
Street: StreetFlop,
|
||||
Position: "BB",
|
||||
HeadsUp: false,
|
||||
NumActive: 4,
|
||||
Stack: 6000,
|
||||
Pot: 800,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "check",
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
{
|
||||
Name: "turn/top pair top kicker multiway checked to",
|
||||
HoleStr: [2]string{"Ac", "Kd"},
|
||||
BoardStr: []string{"Ad", "8s", "3h", "5c"},
|
||||
Street: StreetTurn,
|
||||
Position: "BTN",
|
||||
HeadsUp: false,
|
||||
NumActive: 3,
|
||||
Stack: 7000,
|
||||
Pot: 1500,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "bet",
|
||||
ExpectedThemes: []string{"value"},
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
{
|
||||
Name: "turn/gutshot multiway facing bet",
|
||||
HoleStr: [2]string{"Jd", "Tc"},
|
||||
BoardStr: []string{"Qs", "8h", "3c", "4d"},
|
||||
Street: StreetTurn,
|
||||
Position: "BB",
|
||||
HeadsUp: false,
|
||||
NumActive: 3,
|
||||
Stack: 5000,
|
||||
Pot: 2000,
|
||||
ToCall: 1000,
|
||||
ExpectedAction: "fold",
|
||||
MustNotContain: []string{"raise"},
|
||||
},
|
||||
{
|
||||
Name: "river/trips multiway checked to",
|
||||
HoleStr: [2]string{"9c", "9d"},
|
||||
BoardStr: []string{"9h", "6s", "2d", "Kc", "4h"},
|
||||
Street: StreetRiver,
|
||||
Position: "BTN",
|
||||
HeadsUp: false,
|
||||
NumActive: 3,
|
||||
Stack: 6000,
|
||||
Pot: 1800,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "bet",
|
||||
ExpectedThemes: []string{"value"},
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
{
|
||||
Name: "flop/air multiway facing bet",
|
||||
HoleStr: [2]string{"4c", "3d"},
|
||||
BoardStr: []string{"Ah", "Kd", "Js"},
|
||||
Street: StreetFlop,
|
||||
Position: "BB",
|
||||
HeadsUp: false,
|
||||
NumActive: 4,
|
||||
Stack: 5000,
|
||||
Pot: 1000,
|
||||
ToCall: 500,
|
||||
ExpectedAction: "fold",
|
||||
MustNotContain: []string{"raise", "call"},
|
||||
},
|
||||
{
|
||||
Name: "preflop/TT UTG multiway",
|
||||
HoleStr: [2]string{"Ts", "Th"},
|
||||
Street: StreetPreFlop,
|
||||
Position: "UTG",
|
||||
HeadsUp: false,
|
||||
NumActive: 6,
|
||||
Stack: 10000,
|
||||
Pot: 150,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "raise",
|
||||
ExpectedThemes: []string{"strong"},
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
{
|
||||
Name: "preflop/72o UTG multiway facing raise",
|
||||
HoleStr: [2]string{"7d", "2c"},
|
||||
Street: StreetPreFlop,
|
||||
Position: "UTG",
|
||||
HeadsUp: false,
|
||||
NumActive: 6,
|
||||
Stack: 10000,
|
||||
Pot: 450,
|
||||
ToCall: 300,
|
||||
ExpectedAction: "fold",
|
||||
ExpectedThemes: []string{"weak"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ func buildTipContext(snap tipSnapshot) holdemTipContext {
|
||||
|
||||
holeRanks := [2]int{cardRankIndex(snap.hole[0]), cardRankIndex(snap.hole[1])}
|
||||
holeSuited := snap.hole[0].Suit() == snap.hole[1].Suit()
|
||||
preflopTier := classifyPreflopHand(holeRanks, holeSuited)
|
||||
preflopTier := classifyPreflopHand(holeRanks, holeSuited, snap.headsUp)
|
||||
paired, maxSuit, straightRisk, boardHigh := analyzeBoardTexture(snap.community)
|
||||
|
||||
return holdemTipContext{
|
||||
@@ -258,8 +258,9 @@ func buildTipContext(snap tipSnapshot) holdemTipContext {
|
||||
}
|
||||
|
||||
// classifyPreflopHand buckets a starting hand into one of five tiers.
|
||||
// Sklansky-lite, tuned to keep rules-tip branches manageable.
|
||||
func classifyPreflopHand(ranks [2]int, suited bool) string {
|
||||
// Sklansky-lite for full ring; significantly wider for heads-up where
|
||||
// ranges are ~70% open from BTN and ~55% defend from BB.
|
||||
func classifyPreflopHand(ranks [2]int, suited, headsUp bool) string {
|
||||
hi, lo := ranks[0], ranks[1]
|
||||
if lo > hi {
|
||||
hi, lo = lo, hi
|
||||
@@ -268,6 +269,13 @@ func classifyPreflopHand(ranks [2]int, suited bool) string {
|
||||
gap := hi - lo
|
||||
|
||||
// Rank indices: 2=0, 3=1, ..., 9=7, T=8, J=9, Q=10, K=11, A=12.
|
||||
|
||||
if headsUp {
|
||||
return classifyPreflopHU(hi, lo, pair, gap, suited)
|
||||
}
|
||||
|
||||
// --- Full-ring / 6-max tiers ---
|
||||
|
||||
// Pocket pairs
|
||||
if pair {
|
||||
switch {
|
||||
@@ -324,6 +332,72 @@ func classifyPreflopHand(ranks [2]int, suited bool) string {
|
||||
return "trash"
|
||||
}
|
||||
|
||||
// classifyPreflopHU uses much wider tiers appropriate for heads-up play.
|
||||
// BTN opens ~70%, BB defends ~55%. Any King, any Ace, any pair, most suited
|
||||
// hands, and connected hands are all playable.
|
||||
func classifyPreflopHU(hi, lo int, pair bool, gap int, suited bool) string {
|
||||
// Premium: AA-QQ, AKs, AKo
|
||||
if pair && hi >= 10 {
|
||||
return "premium"
|
||||
}
|
||||
if hi == 12 && lo == 11 {
|
||||
return "premium"
|
||||
}
|
||||
|
||||
// Strong: JJ-88, AQ-ATs, KQs, AQo-AJo, KQo
|
||||
if pair && hi >= 6 { // 88+
|
||||
return "strong"
|
||||
}
|
||||
if hi == 12 && lo >= 8 { // AT+
|
||||
return "strong"
|
||||
}
|
||||
if hi == 11 && lo == 10 { // KQ
|
||||
return "strong"
|
||||
}
|
||||
if suited && hi == 11 && lo >= 9 { // KJs
|
||||
return "strong"
|
||||
}
|
||||
|
||||
// Playable: 77-22, any Ax, Kx (x>=5), Q9+, J9+, T8+, suited connectors
|
||||
if pair {
|
||||
return "playable"
|
||||
}
|
||||
if hi == 12 { // any Ace
|
||||
return "playable"
|
||||
}
|
||||
if hi == 11 && lo >= 3 { // K5+
|
||||
return "playable"
|
||||
}
|
||||
if hi == 10 && lo >= 7 { // Q9+
|
||||
return "playable"
|
||||
}
|
||||
if hi == 9 && lo >= 7 { // J9+
|
||||
return "playable"
|
||||
}
|
||||
if suited && gap <= 3 { // suited connectors/gappers
|
||||
return "playable"
|
||||
}
|
||||
|
||||
// Speculative: Kx (any), suited broadway/connectors, T7+, 97+
|
||||
if hi == 11 { // any remaining King
|
||||
return "speculative"
|
||||
}
|
||||
if suited && hi >= 8 { // any suited T+ hand
|
||||
return "speculative"
|
||||
}
|
||||
if hi == 8 && lo >= 5 { // T7+
|
||||
return "speculative"
|
||||
}
|
||||
if hi == 7 && lo >= 5 { // 97+
|
||||
return "speculative"
|
||||
}
|
||||
if gap <= 2 { // offsuit connectors/gappers
|
||||
return "speculative"
|
||||
}
|
||||
|
||||
return "trash"
|
||||
}
|
||||
|
||||
// analyzeBoardTexture returns (paired, maxSuitCount, straightRisk, highRank).
|
||||
// straightRisk is a 0-4 heuristic: higher means more straight possibilities.
|
||||
// highRank is -1 when there are no community cards.
|
||||
@@ -987,20 +1061,24 @@ func generateRulesTip(ctx holdemTipContext) string {
|
||||
body = generatePostflopTip(ctx)
|
||||
}
|
||||
|
||||
// Position note — small, targeted, only when it actually changes advice.
|
||||
switch ctx.Position {
|
||||
case "BTN", "CO":
|
||||
body += " You have positional advantage — use it."
|
||||
case "UTG":
|
||||
body += " Early position — you need a strong range here."
|
||||
case "SB", "BB":
|
||||
if ctx.Street != StreetPreFlop {
|
||||
body += " Out of position — play tighter on later streets."
|
||||
if !ctx.HeadsUp {
|
||||
switch ctx.Position {
|
||||
case "BTN", "CO":
|
||||
body += " You have positional advantage — use it."
|
||||
case "UTG":
|
||||
body += " Early position — you need a strong range here."
|
||||
case "SB", "BB":
|
||||
if ctx.Street != StreetPreFlop {
|
||||
body += " Out of position — play tighter on later streets."
|
||||
}
|
||||
}
|
||||
if ctx.NumActive >= 4 {
|
||||
body += " Multiway pot — hand values shift; drawing hands improve, bluffs lose value."
|
||||
}
|
||||
} else if ctx.Street != StreetPreFlop {
|
||||
if ctx.Position == "BB" || ctx.Position == "SB" {
|
||||
body += " Out of position heads-up — play cautiously."
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.NumActive >= 4 {
|
||||
body += " Multiway pot — hand values shift; drawing hands improve, bluffs lose value."
|
||||
}
|
||||
|
||||
return body
|
||||
@@ -1037,6 +1115,69 @@ func generateAllInFacingTip(ctx holdemTipContext) string {
|
||||
}
|
||||
|
||||
func generatePreflopTip(ctx holdemTipContext) string {
|
||||
if ctx.HeadsUp {
|
||||
return generatePreflopTipHU(ctx)
|
||||
}
|
||||
return generatePreflopTipRing(ctx)
|
||||
}
|
||||
|
||||
func generatePreflopTipHU(ctx holdemTipContext) string {
|
||||
facing := ctx.ToCall > 0
|
||||
isBTN := ctx.Position == "BTN" || ctx.Position == "SB"
|
||||
deep := ctx.SPR > 10
|
||||
bigRaise := facing && ctx.ToCall > 0 && float64(ctx.ToCall)/float64(ctx.Pot) > 0.6
|
||||
|
||||
switch ctx.PreflopTier {
|
||||
case "premium":
|
||||
if facing {
|
||||
return "Premium hand heads-up — raise or re-raise. You want to build the pot with this hand against any opponent range."
|
||||
}
|
||||
return "Premium hand heads-up — raise for value. Build the pot now."
|
||||
|
||||
case "strong":
|
||||
if facing {
|
||||
return "Strong hand heads-up — raise. This hand plays well in a bigger pot against one opponent."
|
||||
}
|
||||
return "Strong hand heads-up — raise to put pressure on the BB."
|
||||
|
||||
case "playable":
|
||||
if facing && bigRaise && !deep {
|
||||
return "Playable heads-up hand, but the raise is too large relative to your stack — fold. You need deeper stacks to speculate with this."
|
||||
}
|
||||
if facing && isBTN {
|
||||
return "Solid heads-up hand — complete or raise. You have position postflop, which makes this profitable."
|
||||
}
|
||||
if facing {
|
||||
return "Decent heads-up hand — call to see a flop. You're out of position, so keep the pot manageable."
|
||||
}
|
||||
return "Decent heads-up hand — raise to take the initiative."
|
||||
|
||||
case "speculative":
|
||||
if facing && !deep {
|
||||
return "Speculative hand without deep stacks to support it heads-up — fold. You need implied odds to play these."
|
||||
}
|
||||
if facing && isBTN {
|
||||
return "Marginal heads-up hand — completing is fine with position. You'll have the advantage of acting last on every street."
|
||||
}
|
||||
if facing {
|
||||
return "Marginal heads-up hand — calling is borderline. Being out of position makes it harder to realize your equity."
|
||||
}
|
||||
return "Marginal heads-up hand — a small raise can take it down, but don't overcommit."
|
||||
|
||||
case "trash":
|
||||
if facing && isBTN {
|
||||
return "Weak hand, but heads-up from the button you can consider completing cheaply — position makes many hands playable."
|
||||
}
|
||||
if facing {
|
||||
return "Weak hand out of position heads-up — fold. Without position advantage, this hand won't be profitable."
|
||||
}
|
||||
return "Weak hand — check and see a free flop if possible."
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Preflop %.0f%% equity heads-up — evaluate before committing.", (ctx.Equity.Win+ctx.Equity.Tie*0.5)*100)
|
||||
}
|
||||
|
||||
func generatePreflopTipRing(ctx holdemTipContext) string {
|
||||
facing := ctx.ToCall > 0
|
||||
deep := ctx.SPR > 10
|
||||
|
||||
@@ -1075,7 +1216,6 @@ func generatePreflopTip(ctx holdemTipContext) string {
|
||||
return "Weak starting hand — check if you can, otherwise fold. Not worth playing from this position."
|
||||
}
|
||||
|
||||
// Fallback — shouldn't hit, but defensive.
|
||||
return fmt.Sprintf("Preflop %.0f%% equity — evaluate position and stack depth before committing.", (ctx.Equity.Win+ctx.Equity.Tie*0.5)*100)
|
||||
}
|
||||
|
||||
@@ -1088,12 +1228,32 @@ func generatePostflopTip(ctx holdemTipContext) string {
|
||||
pairedBoard := ctx.BoardPaired
|
||||
lowSPR := ctx.SPR < 1.5
|
||||
deepSPR := ctx.SPR > 6
|
||||
multiway := ctx.NumActive >= 3
|
||||
|
||||
// Equity thresholds shift with opponent count. "vs random" equity of 55%
|
||||
// against 3 opponents is much stronger than 55% against 1 — surviving
|
||||
// against multiple random ranges means you likely have a real hand.
|
||||
// Lower the thresholds so we don't undervalue multiway equity.
|
||||
monsterThresh := 0.85
|
||||
strongThresh := 0.70
|
||||
decentThresh := 0.55
|
||||
marginalThresh := 0.40
|
||||
if multiway {
|
||||
monsterThresh = 0.70
|
||||
strongThresh = 0.55
|
||||
decentThresh = 0.40
|
||||
marginalThresh = 0.28
|
||||
}
|
||||
|
||||
// --- Monster / nuts range ---------------------------------------------
|
||||
if equity > 0.85 {
|
||||
if equity > monsterThresh {
|
||||
switch {
|
||||
case lowSPR:
|
||||
return fmt.Sprintf("Monster hand (%.0f%% equity) with a shallow stack — get it in. Shove or call any bet; you're way ahead.", equityPct)
|
||||
case multiway && facing:
|
||||
return fmt.Sprintf("Monster hand (%.0f%% equity) multiway — raise for value. Multiple opponents means a bigger pot when you're this far ahead.", equityPct)
|
||||
case multiway:
|
||||
return fmt.Sprintf("Monster hand (%.0f%% equity) multiway — bet for value. Size up (3/4 pot or more) since someone is likely to call with this many players.", equityPct)
|
||||
case facing && wetBoard:
|
||||
return fmt.Sprintf("Monster hand (%.0f%% equity) but the board is wet — raise for value and to deny equity to draws.", equityPct)
|
||||
case facing:
|
||||
@@ -1106,12 +1266,16 @@ func generatePostflopTip(ctx holdemTipContext) string {
|
||||
}
|
||||
|
||||
// --- Strong made hand --------------------------------------------------
|
||||
if equity > 0.70 {
|
||||
if equity > strongThresh {
|
||||
switch {
|
||||
case pairedBoard && ctx.HandCategory != "" && strings.HasPrefix(ctx.HandCategory, "Full House"):
|
||||
return fmt.Sprintf("Strong hand (%.0f%% equity) — %s. Bet for value; only be cautious if a higher full house is possible.", equityPct, ctx.HandCategory)
|
||||
case pairedBoard:
|
||||
return fmt.Sprintf("Strong hand (%.0f%% equity) but the board is paired — bet for value with caution. Slow down if villain raises big.", equityPct)
|
||||
case multiway && facing:
|
||||
return fmt.Sprintf("Strong hand (%.0f%% equity) multiway — call. Raising bloats the pot against multiple opponents who could have you beat. Re-evaluate on the next street.", equityPct)
|
||||
case multiway:
|
||||
return fmt.Sprintf("Strong hand (%.0f%% equity) multiway — bet for value but size conservatively. Multiple opponents increase the chance someone has a better hand.", equityPct)
|
||||
case facing && monotoneFlop:
|
||||
return fmt.Sprintf("Strong hand (%.0f%% equity) on a monotone board — call and see how villain reacts. Don't bloat the pot without a card in the flush suit.", equityPct)
|
||||
case facing:
|
||||
@@ -1122,8 +1286,15 @@ func generatePostflopTip(ctx holdemTipContext) string {
|
||||
}
|
||||
|
||||
// --- Decent made hand (ahead but vulnerable) ---------------------------
|
||||
if equity > 0.55 {
|
||||
if equity > decentThresh {
|
||||
switch {
|
||||
case multiway && facing:
|
||||
if equity*100 > ctx.PotOddsPct {
|
||||
return fmt.Sprintf("Decent hand (%.0f%% equity) multiway — call. You're priced in, but don't raise into multiple opponents with a vulnerable hand.", equityPct)
|
||||
}
|
||||
return fmt.Sprintf("Decent hand (%.0f%% equity) multiway vs %.0f%% pot odds — fold. Against multiple opponents who've shown interest, you're likely behind.", equityPct, ctx.PotOddsPct)
|
||||
case multiway:
|
||||
return fmt.Sprintf("Decent hand (%.0f%% equity) multiway — check to control the pot. Betting into multiple opponents with a medium-strength hand is risky.", equityPct)
|
||||
case facing && equity*100 > ctx.PotOddsPct && wetBoard:
|
||||
return fmt.Sprintf("Decent hand (%.0f%% equity) on a wet board — call and reassess. Don't raise and bloat the pot out of position.", equityPct)
|
||||
case facing && equity*100 > ctx.PotOddsPct:
|
||||
@@ -1137,9 +1308,32 @@ func generatePostflopTip(ctx holdemTipContext) string {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Marginal / bluffcatcher -------------------------------------------
|
||||
if equity > 0.40 {
|
||||
// --- Drawing hand (check before marginal — draws with low "vs random"
|
||||
// equity in multiway pots are still worth playing for implied odds) ------
|
||||
if ctx.Draw.IsDraw {
|
||||
switch {
|
||||
case !facing && multiway:
|
||||
return fmt.Sprintf("You have a %s. Check for a free card — if you hit, the multiway pot gives you great implied odds.", ctx.Draw.Description)
|
||||
case !facing:
|
||||
return fmt.Sprintf("You have a %s. With a free card available, check and see the next card without risk.", ctx.Draw.Description)
|
||||
case equity*100 > ctx.PotOddsPct:
|
||||
return fmt.Sprintf("Drawing hand (%s) with %.0f%% equity vs %.0f%% pot odds — the price is right to call.", ctx.Draw.Description, equityPct, ctx.PotOddsPct)
|
||||
case multiway && deepSPR && ctx.Draw.TotalOuts >= 8:
|
||||
return fmt.Sprintf("Drawing hand (%s) — pot odds are short but the multiway pot gives you excellent implied odds when you hit. Call.", ctx.Draw.Description)
|
||||
case deepSPR && ctx.Draw.TotalOuts >= 8:
|
||||
return fmt.Sprintf("Drawing hand (%s) — pot odds don't quite justify the call, but deep stacks give you implied odds. Call if you'll get paid when you hit.", ctx.Draw.Description)
|
||||
default:
|
||||
return fmt.Sprintf("Drawing hand (%s) with %.0f%% equity vs %.0f%% pot odds — fold. The price is wrong and the implied odds aren't there.", ctx.Draw.Description, equityPct, ctx.PotOddsPct)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Marginal / bluffcatcher -------------------------------------------
|
||||
if equity > marginalThresh {
|
||||
switch {
|
||||
case multiway && facing:
|
||||
return fmt.Sprintf("Marginal hand (%.0f%% equity) multiway — fold. With multiple opponents still in, someone likely has you beat.", equityPct)
|
||||
case multiway:
|
||||
return fmt.Sprintf("Marginal hand (%.0f%% equity) multiway — check. Don't bluff or bet for thin value into multiple opponents.", equityPct)
|
||||
case facing && equity*100 > ctx.PotOddsPct:
|
||||
return fmt.Sprintf("Marginal hand (%.0f%% equity) — you're barely priced in. Call this bet but don't build the pot further.", equityPct)
|
||||
case facing:
|
||||
@@ -1149,23 +1343,50 @@ func generatePostflopTip(ctx holdemTipContext) string {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Drawing hand ------------------------------------------------------
|
||||
if ctx.Draw.IsDraw {
|
||||
switch {
|
||||
case !facing:
|
||||
return fmt.Sprintf("You have a %s. With a free card available, check and see the next card without risk.", ctx.Draw.Description)
|
||||
case equity*100 > ctx.PotOddsPct:
|
||||
return fmt.Sprintf("Drawing hand (%s) with %.0f%% equity vs %.0f%% pot odds — the price is right to call.", ctx.Draw.Description, equityPct, ctx.PotOddsPct)
|
||||
case deepSPR && ctx.Draw.TotalOuts >= 8:
|
||||
return fmt.Sprintf("Drawing hand (%s) — pot odds don't quite justify the call, but deep stacks give you implied odds. Call if you'll get paid when you hit.", ctx.Draw.Description)
|
||||
default:
|
||||
return fmt.Sprintf("Drawing hand (%s) with %.0f%% equity vs %.0f%% pot odds — fold. The price is wrong and the implied odds aren't there.", ctx.Draw.Description, equityPct, ctx.PotOddsPct)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Weak / air --------------------------------------------------------
|
||||
if facing {
|
||||
return fmt.Sprintf("Weak hand (%.0f%% equity) — fold. You don't have the equity or the draw to continue.", equityPct)
|
||||
}
|
||||
if ctx.HeadsUp {
|
||||
return fmt.Sprintf("Weak hand (%.0f%% equity) — check. You could bluff here, but only if the board favors your perceived range.", equityPct)
|
||||
}
|
||||
return fmt.Sprintf("Weak hand (%.0f%% equity) — check and give up. Don't bluff into multiple opponents or a board you don't represent.", equityPct)
|
||||
}
|
||||
|
||||
// extractTipAction pulls the recommended action verb from a rules tip for audit logging.
|
||||
func extractTipAction(tip string) string {
|
||||
lower := strings.ToLower(tip)
|
||||
for _, action := range []string{"fold", "raise", "bet", "call", "check", "shove"} {
|
||||
if strings.Contains(lower, action) {
|
||||
return action
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// logTipAudit records a tip with full context to the audit table.
|
||||
func logTipAudit(userID string, ctx holdemTipContext, tip string) {
|
||||
eqPct := (ctx.Equity.Win + ctx.Equity.Tie*0.5) * 100
|
||||
drawDesc := ""
|
||||
if ctx.Draw.IsDraw {
|
||||
drawDesc = ctx.Draw.Description
|
||||
}
|
||||
db.RecordTipAudit(
|
||||
userID,
|
||||
ctx.Hole[0]+" "+ctx.Hole[1],
|
||||
ctx.Community,
|
||||
ctx.Street.String(),
|
||||
ctx.Position,
|
||||
ctx.NumActive,
|
||||
eqPct,
|
||||
ctx.Pot,
|
||||
ctx.ToCall,
|
||||
ctx.Stack,
|
||||
ctx.SPR,
|
||||
ctx.HandCategory,
|
||||
drawDesc,
|
||||
ctx.PreflopTier,
|
||||
extractTipAction(tip),
|
||||
tip,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -401,7 +401,7 @@ func TestClassifyPreflopHand(t *testing.T) {
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
ranks := [2]int{cardRankIndex(c.hole[0]), cardRankIndex(c.hole[1])}
|
||||
got := classifyPreflopHand(ranks, c.suited)
|
||||
got := classifyPreflopHand(ranks, c.suited, false)
|
||||
if got != c.want {
|
||||
t.Errorf("classify(%s) = %s, want %s", c.name, got, c.want)
|
||||
}
|
||||
@@ -409,6 +409,38 @@ func TestClassifyPreflopHand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPreflopHandHU(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
hi, lo int
|
||||
suited bool
|
||||
want string
|
||||
}{
|
||||
{"AA", 12, 12, false, "premium"},
|
||||
{"KK", 11, 11, false, "premium"},
|
||||
{"AKo", 12, 11, false, "premium"},
|
||||
{"TT", 8, 8, false, "strong"},
|
||||
{"ATo", 12, 8, false, "strong"},
|
||||
{"K8o", 11, 6, false, "playable"},
|
||||
{"A2o", 12, 0, false, "playable"},
|
||||
{"Q9o", 10, 7, false, "playable"},
|
||||
{"22", 0, 0, false, "playable"},
|
||||
{"K3o", 11, 1, false, "speculative"},
|
||||
{"T7o", 8, 5, false, "speculative"},
|
||||
{"72o", 5, 0, false, "trash"},
|
||||
{"93o", 7, 1, false, "trash"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name+"_HU", func(t *testing.T) {
|
||||
ranks := [2]int{c.hi, c.lo}
|
||||
got := classifyPreflopHand(ranks, c.suited, true)
|
||||
if got != c.want {
|
||||
t.Errorf("classifyHU(%s) = %s, want %s", c.name, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM rewrite guard — keeps action verbs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user