diff --git a/internal/db/db.go b/internal/db/db.go index a102a23..a183e42 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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. diff --git a/internal/plugin/holdem.go b/internal/plugin/holdem.go index f70b2b6..eae6f61 100644 --- a/internal/plugin/holdem.go +++ b/internal/plugin/holdem.go @@ -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) }) } } diff --git a/internal/plugin/holdem_tip_cfr_test.go b/internal/plugin/holdem_tip_cfr_test.go new file mode 100644 index 0000000..d40271c --- /dev/null +++ b/internal/plugin/holdem_tip_cfr_test.go @@ -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 + } +} diff --git a/internal/plugin/holdem_tip_scenarios.go b/internal/plugin/holdem_tip_scenarios.go index 18d4a3c..2c08e90 100644 --- a/internal/plugin/holdem_tip_scenarios.go +++ b/internal/plugin/holdem_tip_scenarios.go @@ -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"}, + }, } diff --git a/internal/plugin/holdem_tips.go b/internal/plugin/holdem_tips.go index 39803d9..019c60c 100644 --- a/internal/plugin/holdem_tips.go +++ b/internal/plugin/holdem_tips.go @@ -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, + ) +} diff --git a/internal/plugin/holdem_tips_test.go b/internal/plugin/holdem_tips_test.go index 93fde5a..0afa934 100644 --- a/internal/plugin/holdem_tips_test.go +++ b/internal/plugin/holdem_tips_test.go @@ -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 // ---------------------------------------------------------------------------