games: the poker table opens, and the bots go back to school

Phase 4. Hold'em, and it's the only table in the casino that is a session
rather than a game: you buy in, play as many hands as you like, and leave with
what's in front of you. So the live row spans hands and chips cross the border
exactly twice. Everything in between is inside the engine.

The bots move inside ApplyMove, as UNO's do, which is what keeps poker off a
socket: shove all-in and the flop, turn, river, showdown and payout all come
back in one response, as a script the felt plays back.

The CFR policy the plan called "the single highest-value asset in either repo"
was never read. Not once, in the whole life of the game: the trainer wrote its
info-set keys under IP/OOP and the runtime looked them up under BTN/SB/BB, so
every lookup missed and fell silently through to a pot-odds heuristic. Nothing
looked broken, because a policy miss is not an error. And it was the wrong
policy anyway — ten big blinds deep, trained on a tree where a call always ends
the street, which is not poker. So the trainer is rewritten to play the real
engine through the real reducer, at every stack depth the table deals, and the
trainer and the table now build the key with the same function so they cannot
drift apart again. A test fails if the bots stop finding themselves in it.

Three money bugs, and the tests earned their keep. Chip conservation across a
hundred sessions caught an uncalled bet that minted chips. A var-init ordering
trap meant every card was identical, every showdown tied and every bot believed
it held exactly 50% equity. And the browser caught the rake being silently
zero — the tier said 5 meaning percent, the casino handed it 0.05 meaning a
fraction, and integer division took the house's cut down to nothing.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
prosolis
2026-07-14 09:08:59 -07:00
parent 6e20883e5d
commit e6c1bd3b54
23 changed files with 4969 additions and 23 deletions

89
cmd/holdem-train/main.go Normal file
View File

@@ -0,0 +1,89 @@
// Command holdem-train trains the casino's poker bots and writes the policy the
// table embeds.
//
// go run ./cmd/holdem-train -iterations 5000000 -out internal/games/holdem/policy.gob
//
// It is not part of Pete. It runs when the engine's rules change, takes half an
// hour, and produces a file. Nothing at runtime imports it.
//
// The bots are trained heads-up, at every stack depth the table deals — that
// range is the point, because poker at twenty big blinds and poker at a hundred
// are different games and a bot that only knows one of them folds into the other.
// A six-handed table then reuses the same policy, which is an approximation, and
// a documented one.
package main
import (
"flag"
"fmt"
"log"
"os"
"runtime"
"time"
"pete/internal/games/holdem"
)
func main() {
iterations := flag.Int("iterations", 5_000_000, "hands to train on")
workers := flag.Int("workers", runtime.NumCPU(), "parallel workers")
out := flag.String("out", "internal/games/holdem/policy.gob", "where to write the policy")
stakes := flag.String("stakes", "low", "which table's blinds to train at")
minBB := flag.Int64("min-bb", 20, "shallowest stack, in big blinds")
maxBB := flag.Int64("max-bb", 100, "deepest stack, in big blinds")
seed := flag.Uint64("seed", 20260714, "seed, so a run can be repeated")
flag.Parse()
tier, err := holdem.TierBySlug(*stakes)
if err != nil {
log.Fatalf("no such table %q", *stakes)
}
fmt.Printf("training %s hands on %d workers — %s blinds, %d%d BB deep\n",
commas(*iterations), *workers, tier.Name, *minBB, *maxBB)
started := time.Now()
last := started
policy := holdem.Train(*iterations, *workers, tier, *minBB, *maxBB, *seed, func(done int) {
if time.Since(last) < 20*time.Second {
return
}
last = time.Now()
frac := float64(done) / float64(*iterations)
if frac <= 0 {
return
}
left := time.Duration(float64(time.Since(started)) * (1 - frac) / frac)
fmt.Printf(" %s / %s hands (%.0f%%), about %s to go\n",
commas(done), commas(*iterations), frac*100, left.Round(time.Second))
})
f, err := os.Create(*out)
if err != nil {
log.Fatal(err)
}
defer f.Close()
if err := holdem.Save(f, policy); err != nil {
log.Fatal(err)
}
info, _ := f.Stat()
fmt.Printf("\ndone in %s: %s nodes, %.1f MB → %s\n",
time.Since(started).Round(time.Second), commas(policy.Meta.Nodes),
float64(info.Size())/(1<<20), *out)
}
func commas(n int) string {
s := fmt.Sprint(n)
if len(s) <= 3 {
return s
}
var out []byte
for i, c := range []byte(s) {
if i > 0 && (len(s)-i)%3 == 0 {
out = append(out, ',')
}
out = append(out, c)
}
return string(out)
}