mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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>
409 lines
13 KiB
Go
409 lines
13 KiB
Go
// 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)
|
||
}
|
||
}
|