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:
prosolis
2026-04-18 14:11:59 -07:00
parent 202056e802
commit 148b8d20f2
6 changed files with 700 additions and 40 deletions

View File

@@ -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. // RecordStartup logs a version startup event.
func RecordStartup(version, commit string) { func RecordStartup(version, commit string) {
Exec("version history", Exec("version history",
@@ -1338,6 +1349,30 @@ CREATE TABLE IF NOT EXISTS tax_ledger (
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 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. // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.

View File

@@ -956,10 +956,12 @@ func (p *HoldemPlugin) sendTurnNotifications(game *HoldemGame) {
if actionPlayer.TipsEnabled { if actionPlayer.TipsEnabled {
snap := snapshotForTip(game, game.ActionIdx) snap := snapshotForTip(game, game.ActionIdx)
dmRoom := actionPlayer.DMRoomID dmRoom := actionPlayer.DMRoomID
playerID := string(actionPlayer.UserID)
safeGo("holdem-tip", func() { safeGo("holdem-tip", func() {
tipCtx := buildTipContext(snap) tipCtx := buildTipContext(snap)
tip := generateTip(tipCtx) tip := generateTip(tipCtx)
p.SendMessage(dmRoom, tip) p.SendMessage(dmRoom, tip)
logTipAudit(playerID, tipCtx, tip)
}) })
} }
} }

View 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
}
}

View File

@@ -104,7 +104,7 @@ var tipScenarios = []TipScenario{
Pot: 300, Pot: 300,
ToCall: 150, ToCall: 150,
ExpectedAction: "call", ExpectedAction: "call",
ExpectedThemes: []string{"deep", "speculative"}, ExpectedThemes: []string{"heads-up"},
}, },
{ {
Name: "preflop/54s BB facing big raise shallow", Name: "preflop/54s BB facing big raise shallow",
@@ -117,7 +117,7 @@ var tipScenarios = []TipScenario{
Pot: 600, Pot: 600,
ToCall: 500, ToCall: 500,
ExpectedAction: "fold", ExpectedAction: "fold",
ExpectedThemes: []string{"speculative"}, ExpectedThemes: []string{"stack"},
}, },
{ {
Name: "preflop/72o BB facing raise", Name: "preflop/72o BB facing raise",
@@ -177,7 +177,7 @@ var tipScenarios = []TipScenario{
Pot: 400, Pot: 400,
ToCall: 0, ToCall: 0,
ExpectedAction: "check", ExpectedAction: "check",
ExpectedThemes: []string{"control"}, ExpectedThemes: []string{"free card"},
MustNotContain: []string{"fold", "call"}, MustNotContain: []string{"fold", "call"},
}, },
{ {
@@ -330,4 +330,197 @@ var tipScenarios = []TipScenario{
ExpectedThemes: []string{"monster"}, ExpectedThemes: []string{"monster"},
MustNotContain: []string{"fold"}, 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"},
},
} }

View File

@@ -226,7 +226,7 @@ func buildTipContext(snap tipSnapshot) holdemTipContext {
holeRanks := [2]int{cardRankIndex(snap.hole[0]), cardRankIndex(snap.hole[1])} holeRanks := [2]int{cardRankIndex(snap.hole[0]), cardRankIndex(snap.hole[1])}
holeSuited := snap.hole[0].Suit() == snap.hole[1].Suit() 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) paired, maxSuit, straightRisk, boardHigh := analyzeBoardTexture(snap.community)
return holdemTipContext{ return holdemTipContext{
@@ -258,8 +258,9 @@ func buildTipContext(snap tipSnapshot) holdemTipContext {
} }
// classifyPreflopHand buckets a starting hand into one of five tiers. // classifyPreflopHand buckets a starting hand into one of five tiers.
// Sklansky-lite, tuned to keep rules-tip branches manageable. // Sklansky-lite for full ring; significantly wider for heads-up where
func classifyPreflopHand(ranks [2]int, suited bool) string { // 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] hi, lo := ranks[0], ranks[1]
if lo > hi { if lo > hi {
hi, lo = lo, hi hi, lo = lo, hi
@@ -268,6 +269,13 @@ func classifyPreflopHand(ranks [2]int, suited bool) string {
gap := hi - lo gap := hi - lo
// Rank indices: 2=0, 3=1, ..., 9=7, T=8, J=9, Q=10, K=11, A=12. // 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 // Pocket pairs
if pair { if pair {
switch { switch {
@@ -324,6 +332,72 @@ func classifyPreflopHand(ranks [2]int, suited bool) string {
return "trash" 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). // analyzeBoardTexture returns (paired, maxSuitCount, straightRisk, highRank).
// straightRisk is a 0-4 heuristic: higher means more straight possibilities. // straightRisk is a 0-4 heuristic: higher means more straight possibilities.
// highRank is -1 when there are no community cards. // highRank is -1 when there are no community cards.
@@ -987,20 +1061,24 @@ func generateRulesTip(ctx holdemTipContext) string {
body = generatePostflopTip(ctx) body = generatePostflopTip(ctx)
} }
// Position note — small, targeted, only when it actually changes advice. if !ctx.HeadsUp {
switch ctx.Position { switch ctx.Position {
case "BTN", "CO": case "BTN", "CO":
body += " You have positional advantage — use it." body += " You have positional advantage — use it."
case "UTG": case "UTG":
body += " Early position — you need a strong range here." body += " Early position — you need a strong range here."
case "SB", "BB": case "SB", "BB":
if ctx.Street != StreetPreFlop { if ctx.Street != StreetPreFlop {
body += " Out of position — play tighter on later streets." 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 return body
@@ -1037,6 +1115,69 @@ func generateAllInFacingTip(ctx holdemTipContext) string {
} }
func generatePreflopTip(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 facing := ctx.ToCall > 0
deep := ctx.SPR > 10 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." 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) 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 pairedBoard := ctx.BoardPaired
lowSPR := ctx.SPR < 1.5 lowSPR := ctx.SPR < 1.5
deepSPR := ctx.SPR > 6 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 --------------------------------------------- // --- Monster / nuts range ---------------------------------------------
if equity > 0.85 { if equity > monsterThresh {
switch { switch {
case lowSPR: 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) 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: 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) return fmt.Sprintf("Monster hand (%.0f%% equity) but the board is wet — raise for value and to deny equity to draws.", equityPct)
case facing: case facing:
@@ -1106,12 +1266,16 @@ func generatePostflopTip(ctx holdemTipContext) string {
} }
// --- Strong made hand -------------------------------------------------- // --- Strong made hand --------------------------------------------------
if equity > 0.70 { if equity > strongThresh {
switch { switch {
case pairedBoard && ctx.HandCategory != "" && strings.HasPrefix(ctx.HandCategory, "Full House"): 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) 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: 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) 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: 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) 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: case facing:
@@ -1122,8 +1286,15 @@ func generatePostflopTip(ctx holdemTipContext) string {
} }
// --- Decent made hand (ahead but vulnerable) --------------------------- // --- Decent made hand (ahead but vulnerable) ---------------------------
if equity > 0.55 { if equity > decentThresh {
switch { 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: 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) 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: case facing && equity*100 > ctx.PotOddsPct:
@@ -1137,9 +1308,32 @@ func generatePostflopTip(ctx holdemTipContext) string {
} }
} }
// --- Marginal / bluffcatcher ------------------------------------------- // --- Drawing hand (check before marginal — draws with low "vs random"
if equity > 0.40 { // equity in multiway pots are still worth playing for implied odds) ------
if ctx.Draw.IsDraw {
switch { 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: 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) 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: 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 -------------------------------------------------------- // --- Weak / air --------------------------------------------------------
if facing { if facing {
return fmt.Sprintf("Weak hand (%.0f%% equity) — fold. You don't have the equity or the draw to continue.", equityPct) 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) 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,
)
}

View File

@@ -401,7 +401,7 @@ func TestClassifyPreflopHand(t *testing.T) {
for _, c := range cases { for _, c := range cases {
t.Run(c.name, func(t *testing.T) { t.Run(c.name, func(t *testing.T) {
ranks := [2]int{cardRankIndex(c.hole[0]), cardRankIndex(c.hole[1])} 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 { if got != c.want {
t.Errorf("classify(%s) = %s, want %s", c.name, 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 // LLM rewrite guard — keeps action verbs
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------