Overhaul hold'em tips: solver-backed scenarios, equity ranges, validation suite

Replaces hardcoded tip scenarios with solver-frequency-backed decisions, adds
equity range display, fixes bet-size matching tolerance (25% threshold), and
adds comprehensive test coverage for scenario validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-18 01:06:50 -07:00
parent 7c450aaefb
commit 42e6e23900
9 changed files with 2675 additions and 89 deletions

View File

@@ -58,7 +58,8 @@ func NewHoldemPlugin(client *mautrix.Client, euro *EuroPlugin) *HoldemPlugin {
}
}
func (p *HoldemPlugin) Name() string { return "holdem" }
func (p *HoldemPlugin) Name() string { return "holdem" }
func (p *HoldemPlugin) Version() string { return "2.1.0" }
func (p *HoldemPlugin) Commands() []CommandDef {
return []CommandDef{
@@ -354,7 +355,8 @@ func (p *HoldemPlugin) handleLeave(ctx MessageContext) error {
// Credit remaining stack back (buy-in was debited at join).
cashout := player.Stack
if !player.IsNPC && cashout > 0 {
p.euro.Credit(player.UserID, float64(cashout), "holdem_cashout")
net, _ := communityTax(player.UserID, float64(cashout), 0.05)
p.euro.Credit(player.UserID, net, "holdem_cashout")
}
// Remove immediately.
p.removePlayer(game, ctx.Sender)
@@ -375,7 +377,8 @@ func (p *HoldemPlugin) handleLeave(ctx MessageContext) error {
// Credit remaining stack back (buy-in was debited at join).
if player.Stack > 0 {
p.euro.Credit(player.UserID, float64(player.Stack), "holdem_cashout")
net, _ := communityTax(player.UserID, float64(player.Stack), 0.05)
p.euro.Credit(player.UserID, net, "holdem_cashout")
}
p.removePlayer(game, ctx.Sender)
@@ -902,7 +905,8 @@ func (p *HoldemPlugin) endHand(game *HoldemGame) {
if pl.WantsLeave || pl.Stack <= 0 {
if !pl.IsNPC {
if pl.Stack > 0 {
p.euro.Credit(pl.UserID, float64(pl.Stack), "holdem_cashout")
net, _ := communityTax(pl.UserID, float64(pl.Stack), 0.05)
p.euro.Credit(pl.UserID, net, "holdem_cashout")
}
p.SendMessage(game.RoomID, fmt.Sprintf("**%s** has left the table.", pl.DisplayName))
}
@@ -919,7 +923,8 @@ func (p *HoldemPlugin) endHand(game *HoldemGame) {
// Cash out remaining players.
for _, pl := range game.Players {
if !pl.IsNPC && pl.Stack > 0 {
p.euro.Credit(pl.UserID, float64(pl.Stack), "holdem_cashout")
net, _ := communityTax(pl.UserID, float64(pl.Stack), 0.05)
p.euro.Credit(pl.UserID, net, "holdem_cashout")
}
}
p.SendMessage(game.RoomID, "Not enough players for another hand. Game over.")

View File

@@ -0,0 +1,211 @@
package plugin
import (
"math/rand/v2"
"github.com/chehsunliu/poker"
)
// HandRange is a flat list of concrete 2-card combos. Expand hand classes
// like "AA", "AKs", "AKo" via expandRange, then optionally drop combos that
// conflict with the hero hole cards and the board.
type HandRange [][2]poker.Card
// expandHandClass converts a canonical hand class into concrete combos.
// "AA" → 6 combos, "AKs" → 4 combos, "AKo" → 12 combos.
// Returns nil for malformed input.
func expandHandClass(class string) HandRange {
if len(class) < 2 || len(class) > 3 {
return nil
}
r1 := string(class[0])
r2 := string(class[1])
isPair := class[0] == class[1]
mode := byte(0)
if len(class) == 3 {
mode = class[2]
}
suits := []string{"s", "h", "d", "c"}
var out HandRange
switch {
case isPair:
for i := 0; i < 4; i++ {
for j := i + 1; j < 4; j++ {
out = append(out, [2]poker.Card{
poker.NewCard(r1 + suits[i]),
poker.NewCard(r2 + suits[j]),
})
}
}
case mode == 's':
for _, s := range suits {
out = append(out, [2]poker.Card{
poker.NewCard(r1 + s),
poker.NewCard(r2 + s),
})
}
case mode == 'o':
for _, s1 := range suits {
for _, s2 := range suits {
if s1 == s2 {
continue
}
out = append(out, [2]poker.Card{
poker.NewCard(r1 + s1),
poker.NewCard(r2 + s2),
})
}
}
}
return out
}
// expandRange concatenates the expansions of each class in the input list.
func expandRange(classes []string) HandRange {
var out HandRange
for _, c := range classes {
out = append(out, expandHandClass(c)...)
}
return out
}
// compatCombos returns combos from r that don't conflict with any card in known.
func compatCombos(r HandRange, known map[poker.Card]bool) HandRange {
out := make(HandRange, 0, len(r))
for _, combo := range r {
if known[combo[0]] || known[combo[1]] || combo[0] == combo[1] {
continue
}
out = append(out, combo)
}
return out
}
// EquityVsRange computes hero's equity when villain's hand is drawn uniformly
// from the given range (typically an all-in stackoff range, not the full
// deck). This is the right number for facing-all-in spots: "vs random"
// systematically overstates high-card hands because nobody shoves random.
func EquityVsRange(hole [2]poker.Card, community []poker.Card, villainRange HandRange, iterations int) EquityResult {
known := make(map[poker.Card]bool, 2+len(community))
known[hole[0]] = true
known[hole[1]] = true
for _, c := range community {
known[c] = true
}
compat := compatCombos(villainRange, known)
if len(compat) == 0 {
return EquityResult{}
}
baseDeck := make([]poker.Card, 0, 52)
for _, c := range allCards() {
if !known[c] {
baseDeck = append(baseDeck, c)
}
}
boardNeeded := 5 - len(community)
var wins, ties, losses int
deck := make([]poker.Card, 0, len(baseDeck))
heroCards := make([]poker.Card, 7)
oppCards := make([]poker.Card, 7)
for it := 0; it < iterations; it++ {
combo := compat[rand.IntN(len(compat))]
// Build this iteration's deck: baseDeck minus villain's two cards.
deck = deck[:0]
for _, c := range baseDeck {
if c == combo[0] || c == combo[1] {
continue
}
deck = append(deck, c)
}
// Partial Fisher-Yates for the first boardNeeded slots.
for j := 0; j < boardNeeded && j < len(deck); j++ {
k := j + rand.IntN(len(deck)-j)
deck[j], deck[k] = deck[k], deck[j]
}
fullBoard := make([]poker.Card, 5)
copy(fullBoard, community)
for b := len(community); b < 5; b++ {
fullBoard[b] = deck[b-len(community)]
}
heroCards[0] = hole[0]
heroCards[1] = hole[1]
copy(heroCards[2:], fullBoard)
heroRank := poker.Evaluate(heroCards)
oppCards[0] = combo[0]
oppCards[1] = combo[1]
copy(oppCards[2:], fullBoard)
oppRank := poker.Evaluate(oppCards)
switch {
case heroRank < oppRank:
wins++
case heroRank == oppRank:
ties++
default:
losses++
}
}
total := float64(iterations)
return EquityResult{
Win: float64(wins) / total,
Tie: float64(ties) / total,
Loss: float64(losses) / total,
}
}
// facingAllInPostflopClasses approximates an opponent's postflop shove range:
// value hands (sets, overpairs, top pair / good kicker) plus strong draws and
// suited broadways — roughly top 13% of hands. This is deliberately tighter
// than a "stackoff range" because what matters when facing a committed shove
// is the range they will actually put in, not the range they'd be willing to
// call off with. Directionally right, not solver-exact.
var facingAllInPostflopClasses = []string{
"AA", "KK", "QQ", "JJ", "TT", "99", "88", "77", "66", "55",
"AKs", "AQs", "AJs", "ATs", "A9s", "A5s", "A4s", "A3s", "A2s",
"AKo", "AQo", "AJo",
"KQs", "KJs", "KTs",
"KQo",
"QJs", "QTs",
"JTs",
"T9s", "98s", "87s", "76s", "65s", "54s",
}
// facingAllInPreflopClasses is a tighter shove range (~13%) for preflop
// all-ins, where ranges are narrower than postflop stackoffs.
var facingAllInPreflopClasses = []string{
"AA", "KK", "QQ", "JJ", "TT", "99", "88", "77",
"AKs", "AQs", "AJs", "ATs",
"AKo", "AQo", "AJo",
"KQs", "KJs", "KTs",
"KQo",
"QJs", "QTs",
"JTs",
}
// Cached expanded ranges.
var (
facingAllInPostflopRange HandRange
facingAllInPreflopRange HandRange
)
func init() {
facingAllInPostflopRange = expandRange(facingAllInPostflopClasses)
facingAllInPreflopRange = expandRange(facingAllInPreflopClasses)
}
// facingAllInRangeFor returns the appropriate shove range for a given street.
func facingAllInRangeFor(street Street) HandRange {
if street == StreetPreFlop {
return facingAllInPreflopRange
}
return facingAllInPostflopRange
}

View File

@@ -0,0 +1,333 @@
package plugin
// TipScenario describes one canonical spot for validating poker tips.
//
// Scenarios are consumed by two layers of automated testing:
//
// 1. Layer 1 — hand-authored: ExpectedAction and ExpectedThemes are filled
// in by a reviewer who picked the "right" answer. The test asserts that
// the rules engine's tip contains the expected action verb and at least
// one theme keyword. Fast, cheap, catches obvious regressions.
//
// 2. Layer 2 — solver-derived: SolverFreqs is populated from a GTO solver
// (e.g. TexasSolver) offline and committed as a fixture. The test
// asserts that the rules engine's action is in the solver's
// significant-frequency set. Slower to generate once, free at test time.
//
// A single scenario can carry either or both layers — the shared test harness
// applies whichever fields are populated. The same struct is also designed to
// eventually feed the runtime dual-perspective tip display, so the schema is
// intentionally broader than just testing.
type TipScenario struct {
Name string
// Game state — minimum needed to reconstruct a holdemTipContext.
HoleStr [2]string // card strings parseable by poker.NewCard (e.g. "As", "9h")
BoardStr []string // community cards; empty for preflop
Street Street
Position string // "BTN", "CO", "MP", "UTG", "SB", "BB"
HeadsUp bool
NumActive int
Stack int64
Pot int64 // total pot before the facing bet
ToCall int64
// Layer 1 — hand-authored expectations.
ExpectedAction string // one of "fold", "check", "call", "bet", "raise"
ExpectedThemes []string // substrings the reasoning should contain
MustNotContain []string // substrings that would indicate a wrong-action bug
// Layer 2 — solver-derived expectations (populated by offline fixture gen).
// Maps action to frequency, e.g. {"call": 0.55, "raise": 0.38, "fold": 0.07}.
// The shared test treats any action with frequency ≥ 0.15 as "valid".
SolverFreqs map[string]float64
}
// TipScenarios returns the canonical library of poker spots used by the
// scenario test harness and the cmd/gensolver offline solver pipeline.
// Exported so tools under cmd/ can iterate the list without duplicating it.
func TipScenarios() []TipScenario { return tipScenarios }
// tipScenarios is the canonical library of poker spots used by the scenario
// test harness. Seed with ~20 cases covering the main decision regions.
// Add more scenarios here as bugs are found or as the rules engine grows.
var tipScenarios = []TipScenario{
// ---- Preflop ---------------------------------------------------------
{
Name: "preflop/AA unopened BTN HU",
HoleStr: [2]string{"As", "Ah"},
Street: StreetPreFlop,
Position: "BTN",
HeadsUp: true,
NumActive: 2,
Stack: 10000,
Pot: 150,
ToCall: 0,
ExpectedAction: "raise",
ExpectedThemes: []string{"premium"},
},
{
Name: "preflop/AKs BTN facing 3bet",
HoleStr: [2]string{"As", "Ks"},
Street: StreetPreFlop,
Position: "BTN",
HeadsUp: true,
NumActive: 2,
Stack: 10000,
Pot: 900,
ToCall: 600,
ExpectedAction: "raise",
ExpectedThemes: []string{"premium"},
},
{
Name: "preflop/TT BTN unopened HU",
HoleStr: [2]string{"Tc", "Td"},
Street: StreetPreFlop,
Position: "BTN",
HeadsUp: true,
NumActive: 2,
Stack: 10000,
Pot: 150,
ToCall: 0,
ExpectedAction: "raise",
ExpectedThemes: []string{"strong"},
},
{
Name: "preflop/54s BB facing min-raise deep",
HoleStr: [2]string{"5h", "4h"},
Street: StreetPreFlop,
Position: "BB",
HeadsUp: true,
NumActive: 2,
Stack: 20000, // deep stacks → implied odds
Pot: 300,
ToCall: 150,
ExpectedAction: "call",
ExpectedThemes: []string{"deep", "speculative"},
},
{
Name: "preflop/54s BB facing big raise shallow",
HoleStr: [2]string{"5h", "4h"},
Street: StreetPreFlop,
Position: "BB",
HeadsUp: true,
NumActive: 2,
Stack: 2000, // shallow → no implied odds
Pot: 600,
ToCall: 500,
ExpectedAction: "fold",
ExpectedThemes: []string{"speculative"},
},
{
Name: "preflop/72o BB facing raise",
HoleStr: [2]string{"7c", "2d"},
Street: StreetPreFlop,
Position: "BB",
HeadsUp: true,
NumActive: 2,
Stack: 10000,
Pot: 300,
ToCall: 200,
ExpectedAction: "fold",
ExpectedThemes: []string{"weak"},
},
// ---- Flop ------------------------------------------------------------
{
Name: "flop/top set on dry board checked to",
HoleStr: [2]string{"Tc", "Td"},
BoardStr: []string{"Th", "2c", "7d"},
Street: StreetFlop,
Position: "BTN",
HeadsUp: true,
NumActive: 2,
Stack: 10000,
Pot: 600,
ToCall: 0,
ExpectedAction: "bet",
ExpectedThemes: []string{"value"},
MustNotContain: []string{"fold", "call"},
},
{
Name: "flop/overpair on wet board facing bet",
HoleStr: [2]string{"Ac", "Ah"},
BoardStr: []string{"9s", "8s", "7s"},
Street: StreetFlop,
Position: "BTN",
HeadsUp: true,
NumActive: 2,
Stack: 5000,
Pot: 1200,
ToCall: 600,
ExpectedAction: "call",
ExpectedThemes: []string{"wet"},
MustNotContain: []string{"check"},
},
{
Name: "flop/flush draw with free card",
HoleStr: [2]string{"7h", "6h"},
BoardStr: []string{"Kh", "9h", "2c"},
Street: StreetFlop,
Position: "BB",
HeadsUp: true,
NumActive: 2,
Stack: 5000,
Pot: 400,
ToCall: 0,
ExpectedAction: "check",
ExpectedThemes: []string{"control"},
MustNotContain: []string{"fold", "call"},
},
{
Name: "flop/OESD facing small bet priced in",
HoleStr: [2]string{"5s", "4s"},
BoardStr: []string{"7c", "6d", "2h"},
Street: StreetFlop,
Position: "BB",
HeadsUp: true,
NumActive: 2,
Stack: 5000,
Pot: 600,
ToCall: 120, // 20% pot odds, OESD has ~32% equity
ExpectedAction: "call",
ExpectedThemes: []string{"price"},
MustNotContain: []string{"check"},
},
{
Name: "flop/bottom pair facing big bet",
HoleStr: [2]string{"2c", "2d"},
BoardStr: []string{"Ks", "Td", "7h"},
Street: StreetFlop,
Position: "BB",
HeadsUp: true,
NumActive: 2,
Stack: 5000,
Pot: 500,
ToCall: 500, // overbet — bad price for a weak hand
ExpectedAction: "fold",
MustNotContain: []string{"check"},
},
// ---- Turn ------------------------------------------------------------
{
Name: "turn/top set facing bet",
HoleStr: [2]string{"Kc", "Kd"},
BoardStr: []string{"Kh", "5c", "2d", "8s"},
Street: StreetTurn,
Position: "BTN",
HeadsUp: true,
NumActive: 2,
Stack: 8000,
Pot: 1500,
ToCall: 750,
ExpectedAction: "raise",
ExpectedThemes: []string{"value"},
MustNotContain: []string{"fold"},
},
{
Name: "turn/combo draw facing half-pot bet",
HoleStr: [2]string{"Jh", "Th"},
BoardStr: []string{"Qh", "9h", "2c", "4d"},
Street: StreetTurn,
Position: "BB",
HeadsUp: true,
NumActive: 2,
Stack: 5000,
Pot: 1000,
ToCall: 500, // 33% pot odds vs 15+ outs
ExpectedAction: "call",
MustNotContain: []string{"check"},
},
{
Name: "turn/weak top pair facing overbet",
HoleStr: [2]string{"6h", "3d"},
BoardStr: []string{"Jd", "9s", "4c", "8h"},
Street: StreetTurn,
Position: "BB",
HeadsUp: true,
NumActive: 2,
Stack: 4000,
Pot: 800,
ToCall: 1200, // overbet — weak kicker on coordinated board
ExpectedAction: "fold",
MustNotContain: []string{"check"},
},
// ---- River -----------------------------------------------------------
{
Name: "river/nut flush checked to us",
HoleStr: [2]string{"Ah", "Qh"},
BoardStr: []string{"Kh", "9h", "2c", "5h", "3d"},
Street: StreetRiver,
Position: "BTN",
HeadsUp: true,
NumActive: 2,
Stack: 5000,
Pot: 1500,
ToCall: 0,
ExpectedAction: "bet",
ExpectedThemes: []string{"charge"},
MustNotContain: []string{"fold", "call"},
},
{
Name: "river/bluffcatcher facing overbet",
HoleStr: [2]string{"5h", "4h"},
BoardStr: []string{"Kh", "7s", "2d", "3c", "9s"},
Street: StreetRiver,
Position: "BTN",
HeadsUp: true,
NumActive: 2,
Stack: 2500,
Pot: 800,
ToCall: 1200, // overbet — polarized, top pair is a bluffcatcher
ExpectedAction: "fold",
MustNotContain: []string{"check"},
},
{
Name: "river/weak hand check available",
HoleStr: [2]string{"7c", "6c"},
BoardStr: []string{"Ah", "Kd", "2s", "8c", "9d"},
Street: StreetRiver,
Position: "BB",
HeadsUp: true,
NumActive: 2,
Stack: 5000,
Pot: 600,
ToCall: 0,
ExpectedAction: "check",
MustNotContain: []string{"call", "raise"},
},
{
Name: "river/second pair facing bet",
HoleStr: [2]string{"6d", "5d"},
BoardStr: []string{"Ac", "Qh", "7d", "3s", "Kc"},
Street: StreetRiver,
Position: "BB",
HeadsUp: true,
NumActive: 2,
Stack: 4000,
Pot: 800,
ToCall: 1200,
ExpectedAction: "fold",
MustNotContain: []string{"check"},
},
{
Name: "flop/monster set on paired board facing bet",
HoleStr: [2]string{"9c", "9d"},
BoardStr: []string{"9h", "9s", "4c"},
Street: StreetFlop,
Position: "BTN",
HeadsUp: true,
NumActive: 2,
Stack: 8000,
Pot: 600,
ToCall: 300,
ExpectedAction: "raise",
ExpectedThemes: []string{"monster"},
MustNotContain: []string{"fold"},
},
}

View File

@@ -0,0 +1,165 @@
package plugin
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/chehsunliu/poker"
)
// loadSolverFixture reads testdata/solver_freqs.json (if present) and merges
// SolverFreqs into matching scenarios. Called once at test start so Layer 2
// activates automatically whenever the fixture has been regenerated.
func loadSolverFixture() {
data, err := os.ReadFile("testdata/solver_freqs.json")
if err != nil {
return
}
m := map[string]map[string]float64{}
if err := json.Unmarshal(data, &m); err != nil {
return
}
for i := range tipScenarios {
if freqs, ok := m[tipScenarios[i].Name]; ok && len(freqs) > 0 {
tipScenarios[i].SolverFreqs = freqs
}
}
}
func TestMain(m *testing.M) {
loadSolverFixture()
os.Exit(m.Run())
}
// ---------------------------------------------------------------------------
// Scenario harness — shared between Layer 1 (hand-authored) and Layer 2
// (solver-derived) tip validation.
// ---------------------------------------------------------------------------
// scenarioContext reconstructs a holdemTipContext the same way the live code
// does, so the test exercises the full pipeline (equity MC, draw detection,
// hand category, board texture, preflop classification).
func scenarioContext(t *testing.T, s TipScenario) holdemTipContext {
t.Helper()
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
}
numOpp := numActive - 1
if numOpp < 1 {
numOpp = 1
}
totalPot := s.Pot
communityS := "—"
if len(community) > 0 {
communityS = renderCards(community)
}
snap := tipSnapshot{
hole: hole,
holeStr: [2]string{renderCard(hole[0]), renderCard(hole[1])},
community: community,
communityS: communityS,
numActive: numActive,
numOpp: numOpp,
toCall: s.ToCall,
totalPot: totalPot,
stack: s.Stack,
street: s.Street,
position: s.Position,
headsUp: s.HeadsUp,
isDealer: s.Position == "BTN" || s.Position == "SB",
}
return buildTipContext(snap)
}
// ---------------------------------------------------------------------------
// Layer 1 — hand-authored scenarios
// ---------------------------------------------------------------------------
func TestTipScenarios_Layer1(t *testing.T) {
for _, s := range tipScenarios {
t.Run(s.Name, func(t *testing.T) {
ctx := scenarioContext(t, s)
tip := generateRulesTip(ctx)
lower := strings.ToLower(tip)
if s.ExpectedAction != "" {
if !strings.Contains(lower, s.ExpectedAction) {
t.Errorf("expected action %q in tip, got: %s", s.ExpectedAction, tip)
}
}
for _, theme := range s.ExpectedThemes {
if !strings.Contains(lower, strings.ToLower(theme)) {
t.Errorf("expected theme %q in tip, got: %s", theme, tip)
}
}
for _, bad := range s.MustNotContain {
if strings.Contains(lower, strings.ToLower(bad)) {
t.Errorf("tip should not contain %q, got: %s", bad, tip)
}
}
})
}
}
// ---------------------------------------------------------------------------
// Layer 2 — solver-derived scenarios
//
// When SolverFreqs is populated (from an offline fixture-generation step
// that calls TexasSolver or an equivalent), we assert that the rules
// engine's recommended action is in the solver's "significant" set —
// i.e. any action the solver picks at least 15% of the time in the
// equilibrium strategy. GTO is mixed: demanding exact top-action match
// would fail legitimately mixed spots.
//
// Until the fixture generator is wired up, this test skips per-scenario
// when SolverFreqs is nil and serves as a scaffolding hook only.
// ---------------------------------------------------------------------------
const solverSignificantFreq = 0.15
func TestTipScenarios_Layer2(t *testing.T) {
for _, s := range tipScenarios {
if s.SolverFreqs == nil {
continue
}
t.Run(s.Name, func(t *testing.T) {
ctx := scenarioContext(t, s)
tip := strings.ToLower(generateRulesTip(ctx))
matched := false
var significant []string
for action, freq := range s.SolverFreqs {
if freq < solverSignificantFreq {
continue
}
significant = append(significant, action)
if strings.Contains(tip, strings.ToLower(action)) {
matched = true
break
}
}
if !matched {
t.Errorf("rules tip did not match any solver-significant action %v: %s", significant, tip)
}
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -310,6 +310,132 @@ func TestRulesTip_DrawFacingBet_GoodOdds(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Rules-based tip — preflop classification
// ---------------------------------------------------------------------------
func TestRulesTip_Preflop_Premium_Open(t *testing.T) {
ctx := holdemTipContext{
Street: StreetPreFlop,
PreflopTier: "premium",
Position: "BTN",
Equity: EquityResult{Win: 0.82, Tie: 0.01, Loss: 0.17},
}
tip := generateRulesTip(ctx)
if !contains(tip, "raise") || contains(tip, "fold") {
t.Errorf("premium open should recommend raise, got: %s", tip)
}
}
func TestRulesTip_Preflop_Trash_FacingRaise(t *testing.T) {
ctx := holdemTipContext{
Street: StreetPreFlop,
PreflopTier: "trash",
Position: "BB",
ToCall: 100,
Equity: EquityResult{Win: 0.15, Tie: 0.0, Loss: 0.85},
}
tip := generateRulesTip(ctx)
if !contains(tip, "fold") {
t.Errorf("trash facing raise should fold, got: %s", tip)
}
}
// ---------------------------------------------------------------------------
// Rules-based tip — postflop action vocabulary
// ---------------------------------------------------------------------------
func TestRulesTip_Marginal_FacingBet_UsesCallOrFold(t *testing.T) {
// Facing a bet must never recommend "bet" or "check".
ctx := holdemTipContext{
Street: StreetRiver,
Equity: EquityResult{Win: 0.66, Tie: 0.0, Loss: 0.34},
ToCall: 400,
PotOddsPct: 25.0,
Position: "BTN",
HandCategory: "One Pair",
HeadsUp: true,
NumActive: 2,
}
tip := generateRulesTip(ctx)
if contains(tip, " bet ") || contains(tip, "check") {
t.Errorf("facing-a-bet tip should not say 'bet' or 'check', got: %s", tip)
}
if !contains(tip, "call") && !contains(tip, "raise") && !contains(tip, "fold") {
t.Errorf("facing-a-bet tip should recommend call/raise/fold, got: %s", tip)
}
}
func TestRulesTip_Monster_BetsForValue(t *testing.T) {
ctx := holdemTipContext{
Street: StreetFlop,
Equity: EquityResult{Win: 0.92, Tie: 0.0, Loss: 0.08},
HandCategory: "Three of a Kind — 9s (set)",
Position: "BTN",
NumActive: 2,
SPR: 5,
}
tip := generateRulesTip(ctx)
if !contains(tip, "value") && !contains(tip, "bet") {
t.Errorf("monster hand should bet for value, got: %s", tip)
}
}
// ---------------------------------------------------------------------------
// Preflop classification
// ---------------------------------------------------------------------------
func TestClassifyPreflopHand(t *testing.T) {
cases := []struct {
name string
hole [2]poker.Card
suited bool
want string
}{
{"AA", [2]poker.Card{poker.NewCard("As"), poker.NewCard("Ah")}, false, "premium"},
{"AKs", [2]poker.Card{poker.NewCard("As"), poker.NewCard("Ks")}, true, "premium"},
{"TT", [2]poker.Card{poker.NewCard("Ts"), poker.NewCard("Th")}, false, "strong"},
{"22", [2]poker.Card{poker.NewCard("2s"), poker.NewCard("2h")}, false, "speculative"},
{"72o", [2]poker.Card{poker.NewCard("7s"), poker.NewCard("2h")}, false, "trash"},
}
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)
if got != c.want {
t.Errorf("classify(%s) = %s, want %s", c.name, got, c.want)
}
})
}
}
// ---------------------------------------------------------------------------
// LLM rewrite guard — keeps action verbs
// ---------------------------------------------------------------------------
func TestRewriteKeepsAction(t *testing.T) {
cases := []struct {
name string
base string
rewrite string
want bool
}{
{"same verb", "You should call here — the price is right.", "Call this one: the price is right.", true},
{"dropped call", "You should call here.", "Raise and apply pressure.", false},
{"fold preserved", "Fold this marginal hand.", "This is a fold — don't call.", true},
{"fold lost", "Fold this hand.", "Call, you're priced in.", false},
{"bet preserved", "Bet 2/3 pot for value.", "Bet two-thirds of the pot for value.", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := rewriteKeepsAction(c.base, c.rewrite)
if got != c.want {
t.Errorf("rewriteKeepsAction(base=%q, rewrite=%q) = %v, want %v", c.base, c.rewrite, got, c.want)
}
})
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchSubstring(s, substr)
}
@@ -322,3 +448,198 @@ func searchSubstring(s, sub string) bool {
}
return false
}
// ---------------------------------------------------------------------------
// Semantic guardrails on LLM rewrites
// ---------------------------------------------------------------------------
func TestRewriteSemanticProblem_RiverFutureStreets(t *testing.T) {
ctx := holdemTipContext{Street: StreetRiver, ToCall: 0}
bad := "Check and let the opponent act first—your hand has only 31 % equity, so the safest move is to stay in the pot and see if they commit; folding after a bet is the correct response."
if r := rewriteSemanticProblem(ctx, bad); r == "" {
t.Errorf("expected river rewrite with future-street + no-bet phrasing to be rejected")
}
}
func TestRewriteSemanticProblem_RiverCleanRewrite(t *testing.T) {
ctx := holdemTipContext{Street: StreetRiver, ToCall: 0}
good := "Check — Queen high is too weak to value bet and you have showdown value against missed draws."
if r := rewriteSemanticProblem(ctx, good); r != "" {
t.Errorf("clean river rewrite should pass, got: %s", r)
}
}
func TestRewriteSemanticProblem_FlopFutureStreetsOK(t *testing.T) {
// Future-street language is fine when we're not on the river.
ctx := holdemTipContext{Street: StreetFlop, ToCall: 0}
ok := "Check to control the pot and see the next card cheaply."
if r := rewriteSemanticProblem(ctx, ok); r != "" {
t.Errorf("flop future-street language should pass, got: %s", r)
}
}
func TestRewriteSemanticProblem_NoBetToFace(t *testing.T) {
ctx := holdemTipContext{Street: StreetTurn, ToCall: 0}
bad := "Check back — folding after a bet would be the right response if they fire."
if r := rewriteSemanticProblem(ctx, bad); r == "" {
t.Errorf("rewrite with 'folding after a bet' when ToCall=0 should be rejected")
}
}
func TestRewriteKeepsAction_BetAsNoun(t *testing.T) {
// Regression: base uses "bet" as a noun ("call this bet"); rewrite uses
// "call" but not "bet". Primary action in base is "call", so accept.
base := "Call this bet — your flush draw has the right price at 33% equity."
rewrite := "Call — the flush draw gets correct odds here."
if !rewriteKeepsAction(base, rewrite) {
t.Errorf("rewrite should be accepted: base primary action is 'call', rewrite preserves it")
}
}
func TestRewriteKeepsAction_ChangesPrimaryAction(t *testing.T) {
base := "Call — your flush draw has the right price."
rewrite := "Fold — this price is too steep."
if rewriteKeepsAction(base, rewrite) {
t.Errorf("rewrite should be rejected: primary action changed from call to fold")
}
}
func TestRewriteKeepsAction_AddsRaiseOnTopOfCall(t *testing.T) {
// The A♠5♠ SB 3bet spot: base recommends call, LLM injects a raise on
// top. Primary action (call) is preserved but rewrite mentions raise,
// which was not in the base.
base := "Speculative hand with deep stacks — call for set-mining or flopping a big draw. Fold the flop if you miss."
rewrite := "Call — your equity is fine, but raise 3-4x the pot to apply maximum pressure."
if rewriteKeepsAction(base, rewrite) {
t.Errorf("rewrite should be rejected: injects raise on top of call/fold base")
}
}
// ---------------------------------------------------------------------------
// Facing all-in — terminal decision branch
// ---------------------------------------------------------------------------
func TestAllInTip_NoFutureStreetLanguage(t *testing.T) {
hole := [2]poker.Card{poker.NewCard("Ac"), poker.NewCard("Kh")}
comm := []poker.Card{poker.NewCard("4c"), poker.NewCard("9c"), poker.NewCard("4h")}
snap := tipSnapshot{
hole: hole, holeStr: [2]string{"A♣", "K♥"},
community: comm, communityS: renderCards(comm),
numActive: 2, numOpp: 1,
toCall: 16600, totalPot: 21200, stack: 18800,
street: StreetFlop, position: "BTN", headsUp: true, isDealer: true,
isAllIn: true,
}
ctx := buildTipContext(snap)
tip := generateRulesTip(ctx)
if !containsCI(tip, "facing an all-in") && !containsCI(tip, "terminal decision") {
t.Errorf("all-in tip should mark the decision as terminal, got: %s", tip)
}
forbidden := []string{"next card", "later street", "position advantage", "see the turn", "fold later", "act after", "future street"}
for _, p := range forbidden {
if containsCI(tip, p) {
t.Errorf("all-in tip must not mention %q, got: %s", p, tip)
}
}
}
func TestAllInTip_UsesVsShoveEquity_NotVsRandom(t *testing.T) {
hole := [2]poker.Card{poker.NewCard("Qs"), poker.NewCard("Js")}
comm := []poker.Card{poker.NewCard("Kh"), poker.NewCard("Th"), poker.NewCard("4d")}
snap := tipSnapshot{
hole: hole, holeStr: [2]string{"Q♠", "J♠"},
community: comm, communityS: renderCards(comm),
numActive: 2, numOpp: 1,
toCall: 800, totalPot: 1000, stack: 3000,
street: StreetFlop, position: "BTN", headsUp: true, isDealer: true,
isAllIn: true,
}
ctx := buildTipContext(snap)
vsRand := ctx.Equity.Win + ctx.Equity.Tie*0.5
vsShove := ctx.EquityVsShove.Win + ctx.EquityVsShove.Tie*0.5
if vsShove >= vsRand {
t.Errorf("OESD should lose equity vs a tight shove range; vsRand=%.2f vsShove=%.2f", vsRand, vsShove)
}
tip := generateRulesTip(ctx)
if !containsCI(tip, "fold") {
t.Errorf("OESD vs flop all-in with 44%% pot odds should recommend fold, got: %s", tip)
}
}
func TestRewriteSemanticProblem_AllInFutureStreets(t *testing.T) {
ctx := holdemTipContext{Street: StreetFlop, ToCall: 16600, IsAllIn: true}
bad := "Call the bet and see the turn. You have positional advantage giving you the ability to fold later if the board turns ugly."
if r := rewriteSemanticProblem(ctx, bad); r == "" {
t.Errorf("all-in rewrite with future-street / position language should be rejected")
}
}
func TestRewriteSemanticProblem_AllInCleanRewrite(t *testing.T) {
ctx := holdemTipContext{Street: StreetFlop, ToCall: 16600, IsAllIn: true}
ok := "Facing an all-in — call. Your equity vs a realistic shove range is well above the pot odds."
if r := rewriteSemanticProblem(ctx, ok); r != "" {
t.Errorf("clean all-in rewrite should pass, got: %s", r)
}
}
func TestEquityVsRange_CompatCombosFiltersConflicts(t *testing.T) {
hole := [2]poker.Card{poker.NewCard("As"), poker.NewCard("Ah")}
community := []poker.Card{poker.NewCard("Ad"), poker.NewCard("Th"), poker.NewCard("5c")}
r := expandRange([]string{"AA"}) // 6 combos of AA
known := map[poker.Card]bool{}
for _, c := range [...]poker.Card{hole[0], hole[1]} {
known[c] = true
}
for _, c := range community {
known[c] = true
}
compat := compatCombos(r, known)
// Hero and board block 3 aces (As, Ah, Ad); only Ac remains — 0 combos
// possible since villain needs 2 aces.
if len(compat) != 0 {
t.Errorf("villain AA should be fully blocked by hero AA + Ad on board, got %d combos", len(compat))
}
}
func TestExpandHandClass_Counts(t *testing.T) {
if n := len(expandHandClass("AA")); n != 6 {
t.Errorf("AA expands to 6 combos, got %d", n)
}
if n := len(expandHandClass("AKs")); n != 4 {
t.Errorf("AKs expands to 4 combos, got %d", n)
}
if n := len(expandHandClass("AKo")); n != 12 {
t.Errorf("AKo expands to 12 combos, got %d", n)
}
if n := len(expandHandClass("garbage")); n != 0 {
t.Errorf("garbage class should return nil/0, got %d", n)
}
}
// containsCI is a case-insensitive contains used by the all-in tests.
func containsCI(s, sub string) bool {
return contains(toLower(s), toLower(sub))
}
func toLower(s string) string {
b := make([]byte, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'A' && c <= 'Z' {
c += 'a' - 'A'
}
b[i] = c
}
return string(b)
}
func TestRewriteKeepsAction_FoldFamilyPresent(t *testing.T) {
// "fold the flop if you miss" in the base legitimises mentioning fold in
// the rewrite.
base := "Speculative hand with deep stacks — call for set-mining. Fold the flop if you miss."
rewrite := "Call and plan to fold the flop unconnected."
if !rewriteKeepsAction(base, rewrite) {
t.Errorf("rewrite should pass: both call and fold are in base")
}
}

View File

@@ -0,0 +1,53 @@
{
"flop/OESD facing small bet priced in": {
"call": 0.9594823122024536,
"fold": 0.000014199958059180062,
"raise": 0.04050343251947197
},
"flop/bottom pair facing big bet": {
"call": 1.662783510880672e-8,
"fold": 0.9999967813491821,
"raise": 0.000003206112978659803
},
"flop/flush draw with free card": {
"bet": 0.0002895280642860598,
"check": 0.9997105002403259
},
"flop/overpair on wet board facing bet": {
"call": 0.9996392726898193,
"fold": 0.0000029239877221698407,
"raise": 0.0003577816241886467
},
"flop/top set on dry board checked to": {
"bet": 0.9198929914534801,
"check": 0.08010699599981308
},
"river/bluffcatcher facing overbet": {
"call": 0,
"fold": 0.9697164297103882,
"raise": 0.03028361313045025
},
"river/nut flush checked to us": {
"bet": 1.0000000298023224,
"check": 0
},
"river/second pair facing bet": {
"call": 0.00007920627831481397,
"fold": 0.9981566071510315,
"raise": 0.0017642288767092396
},
"river/weak hand check available": {
"bet": 0.018236066796816885,
"check": 0.9817639589309692
},
"turn/combo draw facing half-pot bet": {
"call": 0.6370915174484253,
"fold": 0.00001531363341200631,
"raise": 0.3628932002466172
},
"turn/top set facing bet": {
"call": 0.6166651248931885,
"fold": 0,
"raise": 0.3833348592670518
}
}