mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
408
cmd/gensolver/main.go
Normal file
408
cmd/gensolver/main.go
Normal file
@@ -0,0 +1,408 @@
|
||||
// cmd/gensolver drives TexasSolver offline to populate
|
||||
// internal/plugin/testdata/solver_freqs.json for Layer 2 tip scenario tests.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// GOGOBEE_SOLVER=/path/to/console_solver \
|
||||
// GOGOBEE_SOLVER_RESOURCES=/path/to/TexasSolver-v0.2.0-Linux/resources \
|
||||
// go run ./cmd/gensolver [scenario-name-substring]
|
||||
//
|
||||
// If no positional arg is given, every postflop scenario is solved. Results
|
||||
// are *merged* into the existing fixture file — re-running one scenario does
|
||||
// not wipe the others.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/plugin"
|
||||
)
|
||||
|
||||
const fixturePath = "internal/plugin/testdata/solver_freqs.json"
|
||||
|
||||
// Heads-up default ranges. Postflop only — preflop solving needs a full range
|
||||
// tree and is out of scope for our Layer 2 validation, so we skip preflop
|
||||
// scenarios entirely.
|
||||
//
|
||||
// These are deliberately coarse: HU BTN opens wide (~70%), BB defends wide
|
||||
// (~55% vs min-raise). Refine per scenario if solver output looks nonsensical.
|
||||
// TexasSolver range syntax does NOT support the `22+` / `A2s+` shorthand — it
|
||||
// requires explicit enumeration. These two ranges are lifted verbatim from the
|
||||
// solver's own sample input file so we know they parse and produce sensible
|
||||
// equilibria. Not tuned for heads-up specifically; refine later if needed.
|
||||
const (
|
||||
rangeBTNOpen = "AA,KK,QQ,JJ,TT,99:0.75,88:0.75,77:0.5,66:0.25,55:0.25,AK,AQs,AQo:0.75,AJs,AJo:0.5,ATs:0.75,A6s:0.25,A5s:0.75,A4s:0.75,A3s:0.5,A2s:0.5,KQs,KQo:0.5,KJs,KTs:0.75,K5s:0.25,K4s:0.25,QJs:0.75,QTs:0.75,Q9s:0.5,JTs:0.75,J9s:0.75,J8s:0.75,T9s:0.75,T8s:0.75,T7s:0.75,98s:0.75,97s:0.75,96s:0.5,87s:0.75,86s:0.5,85s:0.5,76s:0.75,75s:0.5,65s:0.75,64s:0.5,54s:0.75,53s:0.5,43s:0.5"
|
||||
rangeBBDefend = "QQ:0.5,JJ:0.75,TT,99,88,77,66,55,44,33,22,AKo:0.25,AQs,AQo:0.75,AJs,AJo:0.75,ATs,ATo:0.75,A9s,A8s,A7s,A6s,A5s,A4s,A3s,A2s,KQ,KJ,KTs,KTo:0.5,K9s,K8s,K7s,K6s,K5s,K4s:0.5,K3s:0.5,K2s:0.5,QJ,QTs,Q9s,Q8s,Q7s,JTs,JTo:0.5,J9s,J8s,T9s,T8s,T7s,98s,97s,96s,87s,86s,76s,75s,65s,64s,54s,53s,43s"
|
||||
)
|
||||
|
||||
// SolverNode mirrors the recursive shape of TexasSolver's dump_result JSON.
|
||||
// Every action node has: `actions` (the player-to-act's options), `strategy`
|
||||
// (hand→freq map for those actions), and `childrens` (subtree per action).
|
||||
type SolverNode struct {
|
||||
Actions []string `json:"actions"`
|
||||
Strategy *StrategyBlock `json:"strategy,omitempty"`
|
||||
Childrens map[string]*SolverNode `json:"childrens,omitempty"`
|
||||
NodeType string `json:"node_type,omitempty"`
|
||||
Player int `json:"player,omitempty"`
|
||||
}
|
||||
|
||||
type StrategyBlock struct {
|
||||
Actions []string `json:"actions"`
|
||||
Strategy map[string][]float64 `json:"strategy"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
filter := strings.ToLower(flag.Arg(0))
|
||||
|
||||
solverBin := os.Getenv("GOGOBEE_SOLVER")
|
||||
resourceDir := os.Getenv("GOGOBEE_SOLVER_RESOURCES")
|
||||
if solverBin == "" || resourceDir == "" {
|
||||
fmt.Fprintln(os.Stderr, "set GOGOBEE_SOLVER and GOGOBEE_SOLVER_RESOURCES")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
existing := loadFixture()
|
||||
workDir, err := os.MkdirTemp("", "gensolver-*")
|
||||
must(err)
|
||||
defer os.RemoveAll(workDir)
|
||||
|
||||
solved := 0
|
||||
for _, s := range plugin.TipScenarios() {
|
||||
if s.Street == plugin.StreetPreFlop || len(s.BoardStr) == 0 {
|
||||
continue
|
||||
}
|
||||
if filter != "" && !strings.Contains(strings.ToLower(s.Name), filter) {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("solving: %s\n", s.Name)
|
||||
freqs, err := solveScenario(s, solverBin, resourceDir, workDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " FAILED: %v\n", err)
|
||||
continue
|
||||
}
|
||||
existing[s.Name] = freqs
|
||||
fmt.Printf(" → %v\n", freqs)
|
||||
solved++
|
||||
}
|
||||
|
||||
writeFixture(existing)
|
||||
fmt.Printf("\ndone. %d scenarios solved, fixture written to %s\n", solved, fixturePath)
|
||||
}
|
||||
|
||||
func solveScenario(s plugin.TipScenario, bin, resources, workDir string) (map[string]float64, error) {
|
||||
input := buildInputFile(s)
|
||||
inputPath := filepath.Join(workDir, "input.txt")
|
||||
outputPath := filepath.Join(workDir, "output.json")
|
||||
if err := os.WriteFile(inputPath, []byte(input), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// TexasSolver writes output_result.json to its CWD, so cd into workDir.
|
||||
cmd := exec.Command(bin, "-i", inputPath, "-r", resources)
|
||||
cmd.Dir = workDir
|
||||
cmd.Stdout = os.Stderr // surface solver logs on stderr so JSON doesn't mix in
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("solver failed: %w", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read output: %w", err)
|
||||
}
|
||||
|
||||
return extractHeroFrequencies(data, s)
|
||||
}
|
||||
|
||||
func buildInputFile(s plugin.TipScenario) string {
|
||||
board := strings.Join(s.BoardStr, ",")
|
||||
ipRange, oopRange := rangesFor(s)
|
||||
|
||||
// Normalize to solver-friendly scale: pot=50, stack=8×pot, preserving
|
||||
// SPR. TexasSolver segfaults on large chip counts for certain textures
|
||||
// (suspected internal precision/overflow on some flop trees). Strategic
|
||||
// equivalence holds because GTO frequencies are scale-invariant; only
|
||||
// the raw chip values in action labels change. Caps SPR at 8 to keep
|
||||
// tree build time sane regardless of scenario stack depth.
|
||||
const normalizedPot = 50
|
||||
spr := float64(s.Stack) / float64(s.Pot)
|
||||
if spr > 8 {
|
||||
spr = 8
|
||||
}
|
||||
normalizedStack := int(float64(normalizedPot) * spr)
|
||||
if normalizedStack < 100 {
|
||||
normalizedStack = 100
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "set_pot %d\n", normalizedPot)
|
||||
fmt.Fprintf(&b, "set_effective_stack %d\n", normalizedStack)
|
||||
fmt.Fprintf(&b, "set_board %s\n", board)
|
||||
fmt.Fprintf(&b, "set_range_ip %s\n", ipRange)
|
||||
fmt.Fprintf(&b, "set_range_oop %s\n", oopRange)
|
||||
// Simple bet tree: half-pot + allin each street.
|
||||
for _, side := range []string{"ip", "oop"} {
|
||||
for _, street := range []string{"flop", "turn", "river"} {
|
||||
fmt.Fprintf(&b, "set_bet_sizes %s,%s,bet,50,100\n", side, street)
|
||||
fmt.Fprintf(&b, "set_bet_sizes %s,%s,raise,60\n", side, street)
|
||||
fmt.Fprintf(&b, "set_bet_sizes %s,%s,allin\n", side, street)
|
||||
}
|
||||
}
|
||||
b.WriteString("set_allin_threshold 0.67\n")
|
||||
b.WriteString("build_tree\n")
|
||||
b.WriteString("set_thread_num 8\n")
|
||||
// accuracy 1.0 = stop when total exploitability < 1% of pot. Empirically
|
||||
// this is reached in ~80 iterations on our scenarios vs 120+ for 0.5%,
|
||||
// cutting per-scenario time roughly in half with no practical loss for
|
||||
// validation (we only check if rules engine matches a significant-freq
|
||||
// action, not exact frequencies).
|
||||
b.WriteString("set_accuracy 1.0\n")
|
||||
b.WriteString("set_max_iteration 100\n")
|
||||
b.WriteString("set_print_interval 20\n")
|
||||
// Isomorphism optimization segfaults on certain flop textures (notably
|
||||
// paired boards and some two-tone). Disabling costs ~20% extra solve
|
||||
// time but makes the pipeline reliable.
|
||||
b.WriteString("set_use_isomorphism 0\n")
|
||||
b.WriteString("start_solve\n")
|
||||
b.WriteString("set_dump_rounds 1\n")
|
||||
b.WriteString("dump_result output.json\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func rangesFor(s plugin.TipScenario) (ip, oop string) {
|
||||
// HU: BTN=IP, BB=OOP.
|
||||
return rangeBTNOpen, rangeBBDefend
|
||||
}
|
||||
|
||||
// extractHeroFrequencies walks the dumped tree to find the node where hero
|
||||
// is actually to act, then returns hero's action frequencies for their exact
|
||||
// hole combo, normalized to {check,bet,call,fold,raise}.
|
||||
//
|
||||
// HU postflop ordering: OOP acts first on every street. So the root node is
|
||||
// always OOP's decision.
|
||||
//
|
||||
// - hero=OOP, ToCall=0 → root (OOP first to act, no action yet)
|
||||
// - hero=IP, ToCall=0 → root.childrens["CHECK"] (OOP checked, IP facing check)
|
||||
// - hero=IP, ToCall>0 → root.childrens["BET <x>"] matching ToCall size
|
||||
// - hero=OOP, ToCall>0 → not supported yet (check-bet line, 2 levels deep)
|
||||
func extractHeroFrequencies(data []byte, s plugin.TipScenario) (map[string]float64, error) {
|
||||
var root SolverNode
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
return nil, fmt.Errorf("parse json: %w", err)
|
||||
}
|
||||
|
||||
heroIP := s.Position == "BTN" || s.Position == "SB"
|
||||
// Normalize ToCall to the solver's chip scale (pot=50) so bet-child
|
||||
// matching works after the pot/stack normalization in buildInputFile.
|
||||
normalizedToCall := float64(s.ToCall) * 50.0 / float64(s.Pot)
|
||||
node, err := navigateToHero(&root, heroIP, normalizedToCall)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node.Strategy == nil {
|
||||
return nil, fmt.Errorf("hero node has no strategy (node_type=%s)", node.NodeType)
|
||||
}
|
||||
|
||||
key := holeKey(s.HoleStr)
|
||||
raw, ok := node.Strategy.Strategy[key]
|
||||
if !ok {
|
||||
raw, ok = node.Strategy.Strategy[flipHole(key)]
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("hole %q not found in strategy (tried %q)", key, flipHole(key))
|
||||
}
|
||||
if len(raw) != len(node.Strategy.Actions) {
|
||||
return nil, fmt.Errorf("action/freq length mismatch: %d vs %d", len(raw), len(node.Strategy.Actions))
|
||||
}
|
||||
|
||||
out := map[string]float64{}
|
||||
for i, act := range node.Strategy.Actions {
|
||||
out[normalizeAction(act)] += raw[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func navigateToHero(root *SolverNode, heroIP bool, toCall float64) (*SolverNode, error) {
|
||||
// HU postflop: OOP always acts first on each street, so root is OOP's node.
|
||||
switch {
|
||||
case !heroIP && toCall == 0:
|
||||
// OOP first to act, no prior action.
|
||||
return root, nil
|
||||
|
||||
case heroIP && toCall == 0:
|
||||
// OOP checked → IP facing check.
|
||||
return childByLabel(root, "CHECK")
|
||||
|
||||
case heroIP && toCall > 0:
|
||||
// OOP donk-bet (or we're mid-street with OOP having bet first). Find
|
||||
// the BET child whose chip amount is closest to the scenario's ToCall.
|
||||
return childByBetSize(root, toCall)
|
||||
|
||||
case !heroIP && toCall > 0:
|
||||
// Check-bet line: OOP checks → IP bets → OOP facing bet.
|
||||
checkNode, err := childByLabel(root, "CHECK")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check-bet line: %w", err)
|
||||
}
|
||||
return childByBetSize(checkNode, toCall)
|
||||
}
|
||||
return nil, fmt.Errorf("unreachable")
|
||||
}
|
||||
|
||||
func childByLabel(node *SolverNode, label string) (*SolverNode, error) {
|
||||
child, ok := node.Childrens[label]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no %q child; available: %v", label, keysOf(node.Childrens))
|
||||
}
|
||||
return child, nil
|
||||
}
|
||||
|
||||
// childByBetSize picks the BET child whose chip amount is closest to toCall.
|
||||
// Rejects the match if the nearest bet size differs by more than 25% — that
|
||||
// usually means the solver wasn't configured with a comparable sizing and the
|
||||
// returned frequencies would describe a different decision.
|
||||
func childByBetSize(node *SolverNode, toCall float64) (*SolverNode, error) {
|
||||
var best *SolverNode
|
||||
var bestAmt float64
|
||||
bestDelta := 1e18
|
||||
for label, child := range node.Childrens {
|
||||
if !strings.HasPrefix(label, "BET") {
|
||||
continue
|
||||
}
|
||||
amt := parseBetAmount(label)
|
||||
if amt < 0 {
|
||||
continue
|
||||
}
|
||||
d := amt - toCall
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if d < bestDelta {
|
||||
bestDelta = d
|
||||
bestAmt = amt
|
||||
best = child
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil, fmt.Errorf("no BET child matching toCall=%v; available: %v", toCall, keysOf(node.Childrens))
|
||||
}
|
||||
if toCall > 0 && bestDelta/toCall > 0.25 {
|
||||
return nil, fmt.Errorf("nearest BET child %.2f is >25%% off toCall=%.2f; solver sizings don't cover this spot; available: %v", bestAmt, toCall, keysOf(node.Childrens))
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
func parseBetAmount(label string) float64 {
|
||||
// "BET 25.000000" → 25
|
||||
parts := strings.Fields(label)
|
||||
if len(parts) != 2 {
|
||||
return -1
|
||||
}
|
||||
var v float64
|
||||
if _, err := fmt.Sscanf(parts[1], "%f", &v); err != nil {
|
||||
return -1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func keysOf(m map[string]*SolverNode) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// holeKey builds TexasSolver's hand key: two cards with higher rank first.
|
||||
// Ranks: 2..9,T,J,Q,K,A. Suit order for same-rank pairs: s > h > d > c.
|
||||
func holeKey(hole [2]string) string {
|
||||
a, b := hole[0], hole[1]
|
||||
if cardLess(b, a) {
|
||||
return a + b
|
||||
}
|
||||
return b + a
|
||||
}
|
||||
|
||||
func flipHole(k string) string {
|
||||
if len(k) != 4 {
|
||||
return k
|
||||
}
|
||||
return k[2:] + k[:2]
|
||||
}
|
||||
|
||||
func cardLess(a, b string) bool {
|
||||
ra := rankIdx(a[0])
|
||||
rb := rankIdx(b[0])
|
||||
if ra != rb {
|
||||
return ra < rb
|
||||
}
|
||||
return suitIdx(a[1]) < suitIdx(b[1])
|
||||
}
|
||||
|
||||
func rankIdx(r byte) int {
|
||||
return strings.IndexByte("23456789TJQKA", r)
|
||||
}
|
||||
|
||||
func suitIdx(s byte) int {
|
||||
return strings.IndexByte("cdhs", s)
|
||||
}
|
||||
|
||||
func normalizeAction(a string) string {
|
||||
a = strings.ToLower(a)
|
||||
switch {
|
||||
case strings.HasPrefix(a, "check"):
|
||||
return "check"
|
||||
case strings.HasPrefix(a, "fold"):
|
||||
return "fold"
|
||||
case strings.HasPrefix(a, "call"):
|
||||
return "call"
|
||||
case strings.HasPrefix(a, "bet"):
|
||||
return "bet"
|
||||
case strings.HasPrefix(a, "raise"):
|
||||
return "raise"
|
||||
case strings.Contains(a, "allin"):
|
||||
return "raise"
|
||||
default:
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
func loadFixture() map[string]map[string]float64 {
|
||||
out := map[string]map[string]float64{}
|
||||
data, err := os.ReadFile(fixturePath)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
_ = json.Unmarshal(data, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func writeFixture(m map[string]map[string]float64) {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
ordered := make(map[string]map[string]float64, len(m))
|
||||
for _, k := range keys {
|
||||
ordered[k] = m[k]
|
||||
}
|
||||
data, err := json.MarshalIndent(ordered, "", " ")
|
||||
must(err)
|
||||
must(os.MkdirAll(filepath.Dir(fixturePath), 0o755))
|
||||
must(os.WriteFile(fixturePath, append(data, '\n'), 0o644))
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
319
fix-holdem-tips.md
Normal file
319
fix-holdem-tips.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# Fix: Texas Hold'em LLM Tips
|
||||
|
||||
## What's broken
|
||||
|
||||
Two confirmed issues observed across multiple tip examples:
|
||||
|
||||
### 1. Position label is inverted in heads-up play
|
||||
|
||||
The tip says "positional advantage" when the player is acting first post-flop (out of position) and "out of position" when they're acting last. The position label reaching the LLM prompt is wrong.
|
||||
|
||||
**Root cause:** the `positionLabel()` function in `tips.go` derives position from `DealerIdx` using the general formula. In heads-up play the dealer posts the small blind and acts first pre-flop but **last** post-flop. The heads-up exception that exists in `PostBlinds()` in `betting.go` is not being reflected in position label calculation.
|
||||
|
||||
**Fix:** in `positionLabel()`, gate on `len(g.Players) == 2` before applying any label logic. In heads-up:
|
||||
- Pre-flop: dealer = BTN/SB (acts first), other player = BB (acts last)
|
||||
- Post-flop: dealer = BTN (acts last, positional advantage), other player = BB (acts first, out of position)
|
||||
|
||||
Check which street it is before assigning the label. `g.Street == PreFlop` needs different position semantics than all other streets in heads-up.
|
||||
|
||||
---
|
||||
|
||||
### 2. LLM is generating generic concepts instead of hand-specific advice
|
||||
|
||||
**Observed:** tips reference equity numbers but then ignore what those numbers mean for the specific hand. A player with 8♥ 7♥ on Q♥ K♠ 10♦ (gutshot + backdoor flush draw, 29% equity, free card available) received "not enough equity to bet" — which ignores the draw entirely and misapplies a made-hand concept to a drawing hand.
|
||||
|
||||
**Root cause:** the user prompt is not giving the LLM enough structured context to reason about hand *type*. It sees an equity number but doesn't know whether the hand is a draw, a made hand, a bluff catcher, or air. It pattern-matches on the number alone.
|
||||
|
||||
**Fix:** compute and inject the following additional fields into `TipContext` before building the prompt:
|
||||
|
||||
```go
|
||||
type TipContext struct {
|
||||
// existing fields...
|
||||
|
||||
// Add these:
|
||||
HandCategory string // from poker.RankString() on current 5-card best
|
||||
IsDraw bool // true if outs > 0 (see below)
|
||||
FlushDrawOuts int // suited cards matching board suit count
|
||||
StraightDrawOuts int // connected card gaps to straight
|
||||
TotalOuts int // combined draw outs (deduped)
|
||||
IsFreeCard bool // ToCall == 0
|
||||
HeadsUp bool // len(ActivePlayers) == 2
|
||||
}
|
||||
```
|
||||
|
||||
Outs calculation (add to `equity.go` or a new `outs.go`):
|
||||
- Flush draw: count hole cards matching dominant board suit; if 2 hole cards + 2 board cards same suit, FlushDrawOuts = 9
|
||||
- Open-ended straight draw: 8 outs
|
||||
- Gutshot: 4 outs
|
||||
- Backdoor draws: count as 1-2 outs each
|
||||
- TotalOuts = sum, capped at 15 (avoid double-counting straights and flushes)
|
||||
|
||||
---
|
||||
|
||||
### 3. System prompt needs to be more directive
|
||||
|
||||
**Current system prompt** (paraphrased from blueprint): "be a concise Hold'em coach, 2-4 sentences, cover hand strength, pot odds, position."
|
||||
|
||||
This is too open-ended. The LLM fills the space with whatever poker concepts come to mind. Replace with a prompt that forces it to reason about the specific situation before speaking.
|
||||
|
||||
**New system prompt:**
|
||||
|
||||
```
|
||||
You are a Texas Hold'em coach giving advice to a single player via private message.
|
||||
You will receive structured game context. Reason through it in this order:
|
||||
|
||||
1. What type of hand do I have — made hand, drawing hand, or air?
|
||||
2. If drawing: how many outs, and do pot odds justify continuing?
|
||||
3. If made hand: is it strong enough to bet for value, or weak enough to just pot control?
|
||||
4. Does position affect what I should do here?
|
||||
5. Is a free card available, and if so, is taking it correct?
|
||||
|
||||
Then write ONE piece of advice — 2 to 3 sentences maximum — that tells the player
|
||||
what to do and why, using the specific cards and numbers provided.
|
||||
Do not list concepts. Do not use generic poker vocabulary without connecting it to
|
||||
this specific hand. If the correct play is obvious (e.g. free card with a draw),
|
||||
say so plainly and briefly.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. User prompt needs draw and hand type context injected
|
||||
|
||||
**Current user prompt structure** (from blueprint):
|
||||
```
|
||||
Street: <street>
|
||||
Your hand: <cards>
|
||||
Board: <cards>
|
||||
Equity vs <n> opponents: Win x% | Tie y% | Loss z%
|
||||
Pot odds to call: x%
|
||||
SPR: x | Position: <pos> | Active players: <n>
|
||||
```
|
||||
|
||||
**New user prompt structure** — add the computed fields:
|
||||
|
||||
```
|
||||
Street: {street}
|
||||
Your hand: {cards} [{hand_category}]
|
||||
Board: {cards}
|
||||
Draw outs: {total_outs} ({draw_description}) <- omit line if IsDraw == false
|
||||
Equity vs {n} opponent(s): Win {x}% | Tie {y}% | Loss {z}%
|
||||
{if ToCall > 0}: Pot odds to call: {pct}% — equity {exceeds|falls short of} price
|
||||
{if IsFreeCard}: Free card available — no bet to call
|
||||
SPR: {spr} | Position: {position} | Heads-up: {yes|no} | Street: {street}
|
||||
```
|
||||
|
||||
`{draw_description}` examples:
|
||||
- "flush draw (9 outs)"
|
||||
- "gutshot straight draw (4 outs)"
|
||||
- "open-ended straight draw (8 outs)"
|
||||
- "flush draw + gutshot (11 outs)"
|
||||
- "backdoor flush + backdoor straight (2 outs)"
|
||||
|
||||
`{hand_category}` examples from `poker.RankString()`:
|
||||
- "High Card", "One Pair", "Two Pair", "Three of a Kind", "Straight", "Flush", "Full House", "Four of a Kind", "Straight Flush"
|
||||
|
||||
---
|
||||
|
||||
## Specific scenario the fix must handle correctly
|
||||
|
||||
**Hand:** 8♥ 7♥
|
||||
**Board:** Q♥ K♠ 10♦
|
||||
**Street:** Flop
|
||||
**Equity:** 29%
|
||||
**To call:** €0 (free card)
|
||||
**Position:** dealer, heads-up, acting first post-flop (out of position)
|
||||
|
||||
Expected tip behaviour after fix:
|
||||
- Identifies this as a drawing hand (gutshot + backdoor flush)
|
||||
- Notes the free card is available
|
||||
- Does NOT say "not enough equity to bet" without acknowledging the draw
|
||||
- Does NOT say "positional advantage" — player is out of position post-flop heads-up
|
||||
- Produces something like: "You have a gutshot straight draw with a backdoor flush. With a free card available you can check and see the turn without risk. If a 9 or a third heart comes, you'll be in a strong position — for now, take the free card."
|
||||
|
||||
---
|
||||
|
||||
## Reasoning mode (Qwen3 thinking)
|
||||
|
||||
Poker tips are the only task in GogoBee that should use reasoning mode. All other LLM calls (adventure narrative, etc.) run with thinking disabled. This needs to be a one-off configuration scoped entirely to `tips.go`.
|
||||
|
||||
### Why reasoning mode here
|
||||
|
||||
The tips failure pattern is not a knowledge gap — Qwen3-32B knows poker. The problem is that it jumps to pattern-matched conclusions without working through the situation in sequence. Reasoning mode forces the model to produce a `<think>...</think>` chain before the final response, which naturally surfaces: hand type, outs, position semantics, and the actual decision. The tip then follows from that chain rather than being assembled from disconnected concepts.
|
||||
|
||||
### Request changes in `tips.go`
|
||||
|
||||
Add a `enable_thinking` field to the request body and a `thinking_budget` cap to keep latency bounded:
|
||||
|
||||
```go
|
||||
type llmRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []llmMessage `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Stream bool `json:"stream"`
|
||||
EnableThinking bool `json:"enable_thinking,omitempty"`
|
||||
ThinkingBudget int `json:"thinking_budget,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
When building the tips request, set:
|
||||
|
||||
```go
|
||||
body := llmRequest{
|
||||
Model: cfg.Model,
|
||||
Messages: []llmMessage{...},
|
||||
MaxTokens: 1000, // increased to accommodate think block + response
|
||||
Stream: false,
|
||||
EnableThinking: true,
|
||||
ThinkingBudget: 512, // cap reasoning tokens; enough for poker, not runaway
|
||||
}
|
||||
```
|
||||
|
||||
`ThinkingBudget` of 512 tokens is sufficient for a poker hand analysis reasoning chain. Without a cap, complex board textures can produce very long think blocks. 512 keeps worst-case latency reasonable.
|
||||
|
||||
Note: the exact field names for Ollama's Qwen3 thinking mode may differ from the above. Check the Ollama API docs for the current `qwen3:32b` thinking parameters — it may be `/think` appended to the model name (`qwen3:32b/think`) rather than a request body field, depending on the Ollama version. Either way, the intent is the same — make this configurable in `TipsConfig` so it can be toggled without a code change:
|
||||
|
||||
```go
|
||||
type TipsConfig struct {
|
||||
Endpoint string
|
||||
Model string
|
||||
APIKey string
|
||||
Timeout time.Duration
|
||||
EnableThinking bool // default true for poker tips
|
||||
ThinkingBudget int // default 512
|
||||
}
|
||||
```
|
||||
|
||||
### Strip the think block from the response
|
||||
|
||||
The `<think>...</think>` content must never reach the player DM. The current response parser takes `choices[0].message.content` directly. Update it to strip thinking content before returning:
|
||||
|
||||
```go
|
||||
func extractTipFromResponse(raw string) string {
|
||||
// Strip <think>...</think> block if present
|
||||
// Qwen3 may use <think> or <!--think--> depending on version
|
||||
re := regexp.MustCompile(`(?s)<think>.*?</think>`)
|
||||
cleaned := re.ReplaceAllString(raw, "")
|
||||
|
||||
// Also strip any leading/trailing whitespace left behind
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
```
|
||||
|
||||
Call `extractTipFromResponse()` on `llmResp.Choices[0].Message.Content` before returning the tip string. If the result is empty after stripping (model only produced a think block and nothing else), fall back to the rules-based tip.
|
||||
|
||||
### Latency expectations
|
||||
|
||||
With `ThinkingBudget: 512` and the structured context prompt, expect:
|
||||
- Typical: 4-8 seconds total (within the existing 10s timeout)
|
||||
- Complex boards: up to 10 seconds
|
||||
- Increase `cfg.Timeout` to `12 * time.Second` for tips specifically to give reasoning room without affecting other LLM calls
|
||||
|
||||
Tip delivery via DM is already async (goroutine), so even a 10-12 second tip doesn't block the table view or the action loop. Players receive the table view immediately and the tip follows shortly after.
|
||||
|
||||
### Config addition
|
||||
|
||||
```toml
|
||||
[holdem]
|
||||
# ... existing fields ...
|
||||
tips_enable_thinking = true
|
||||
tips_thinking_budget = 512
|
||||
tips_timeout = "12s" # longer than default to accommodate reasoning
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to change
|
||||
|
||||
- `tips.go` — `TipContext` struct, `BuildTipContext()`, `buildPrompt()`, `positionLabel()`, `llmRequest` struct, `GenerateTip()`, new `extractTipFromResponse()` function
|
||||
- `equity.go` — add outs calculation function
|
||||
- No schema changes required
|
||||
- No changes to `game.go`, `betting.go`, or `render.go`
|
||||
|
||||
## Test cases to verify before shipping
|
||||
|
||||
Write a table-driven test in `tips_test.go` covering:
|
||||
|
||||
| Hand | Board | Street | Expected position (HU) | Expected IsDraw | Expected outs |
|
||||
|------|-------|--------|------------------------|-----------------|---------------|
|
||||
| 8♥ 7♥ | Q♥ K♠ 10♦ | Flop | Out of position | true | 4 (gutshot) + backdoor |
|
||||
| A♠ K♠ | — | Pre-Flop | BTN (dealer, acts first) | false | 0 |
|
||||
| 5♥ 6♥ | 7♥ 8♣ 2♥ | Flop | varies | true | 15 (OESD + flush) |
|
||||
| Q♣ Q♦ | Q♥ 2♠ 7♣ | Flop | varies | false | 0 |
|
||||
|
||||
The position test for heads-up pre-flop vs post-flop is the most important one. Get that right first.
|
||||
|
||||
---
|
||||
|
||||
## Validation pipeline (shipped 2026-04-13)
|
||||
|
||||
The "is the tip actually good?" question is now answered by a two-layer
|
||||
automated test harness rather than vibes.
|
||||
|
||||
**Layer 1 — hand-authored scenarios** (`internal/plugin/holdem_tip_scenarios.go`)
|
||||
|
||||
20 canonical spots covering preflop tier/facing-bet branches and postflop
|
||||
equity tiers × board textures × SPR depths. Each scenario declares an
|
||||
expected action verb, required theme keywords, and forbidden substrings.
|
||||
`TestTipScenarios_Layer1` runs the full rules-engine pipeline
|
||||
(equity MC, draw detection, hand category, board texture, preflop
|
||||
classification) against each scenario and asserts the tip contains the
|
||||
expected action + themes. Fast, cheap, green.
|
||||
|
||||
**Layer 2 — solver-derived scenarios** (same scenarios, populated via `cmd/gensolver`)
|
||||
|
||||
11 of the 14 postflop scenarios carry real TexasSolver GTO frequencies
|
||||
committed as a fixture at `internal/plugin/testdata/solver_freqs.json`.
|
||||
`TestTipScenarios_Layer2` treats any action with solver frequency ≥ 15% as
|
||||
"significant" and asserts the rules engine's recommended action matches one
|
||||
of the significant actions — tolerating GTO's legitimately mixed spots
|
||||
while catching genuinely-wrong recommendations.
|
||||
|
||||
**cmd/gensolver**
|
||||
|
||||
Offline pipeline that iterates `plugin.TipScenarios()`, shells out to
|
||||
`console_solver` (TexasSolver CLI), parses the JSON strategy tree,
|
||||
navigates to hero's decision node (IP/OOP × facing-check/facing-bet ×
|
||||
check-bet line), extracts hero's action frequencies for their exact hole
|
||||
combo, and merges them into the fixture file.
|
||||
|
||||
Key solver-side knobs worked out the hard way:
|
||||
|
||||
- **Scale normalization** to `pot=50, stack=8×pot` (SPR cap 8). TexasSolver
|
||||
segfaults on deep stacks and on some textures at larger chip counts;
|
||||
strategic equivalence is preserved because GTO frequencies are
|
||||
scale-invariant.
|
||||
- **Bet tree**: 50% + 100% pot sizings, plus allin. Narrower trees build
|
||||
faster and still give solvable decision points.
|
||||
- **`set_accuracy 1.0`, `set_max_iteration 100`** — converges in ~2 min
|
||||
per flop instead of the ~24 min the solver's defaults demanded. 1%
|
||||
exploitability is plenty for our assertion type.
|
||||
- **Range syntax**: TexasSolver rejects shorthand like `22+` / `A2s+` —
|
||||
ranges must be explicitly enumerated. Using the solver's own
|
||||
sample-input ranges verbatim as HU defaults.
|
||||
|
||||
Invocation:
|
||||
|
||||
```bash
|
||||
GOGOBEE_SOLVER=/path/to/console_solver \
|
||||
GOGOBEE_SOLVER_RESOURCES=/path/to/TexasSolver/resources \
|
||||
go run ./cmd/gensolver [scenario-name-substring]
|
||||
```
|
||||
|
||||
Results merge into the fixture, so regenerating one scenario doesn't wipe
|
||||
the others.
|
||||
|
||||
**Known gaps** — 3 scenarios have no solver frequencies:
|
||||
|
||||
- `flop/monster set on paired board facing bet` — TexasSolver segfaults on
|
||||
paired-board textures (upstream bug, not fixable from our side).
|
||||
- `turn/weak top pair facing overbet` — hero's hole (63o) isn't in any
|
||||
reasonable HU range, so the solver never allocates strategy for it.
|
||||
Scenario still validated by Layer 1.
|
||||
- Occasional flake on `flop/bottom pair facing big bet` at full-batch time
|
||||
(succeeds when retried solo). Current fixture entry came from a solo
|
||||
retry and is valid; if regeneration fails, just re-run that one
|
||||
scenario with the name filter.
|
||||
|
||||
Adding new scenarios: append to `tipScenarios` in
|
||||
`holdem_tip_scenarios.go`, run `cmd/gensolver` with the name filter,
|
||||
commit both the code and fixture changes together.
|
||||
@@ -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.")
|
||||
|
||||
211
internal/plugin/holdem_equity_range.go
Normal file
211
internal/plugin/holdem_equity_range.go
Normal 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
|
||||
}
|
||||
333
internal/plugin/holdem_tip_scenarios.go
Normal file
333
internal/plugin/holdem_tip_scenarios.go
Normal 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"},
|
||||
},
|
||||
}
|
||||
165
internal/plugin/holdem_tip_scenarios_test.go
Normal file
165
internal/plugin/holdem_tip_scenarios_test.go
Normal 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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
53
internal/plugin/testdata/solver_freqs.json
vendored
Normal file
53
internal/plugin/testdata/solver_freqs.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user