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

@@ -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)
})
}
}

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,
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"},
},
}

View File

@@ -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,
)
}

View File

@@ -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
// ---------------------------------------------------------------------------