A review pass, and it found the one that would have cost somebody real chips. Side pots were only ever cut in runout() — the path taken when the betting stops because nobody is left able to bet. But a hand reaches a showdown with an all-in player in it and the betting having finished perfectly normally: a short stack shoves, two players who still have chips behind call, and then keep betting past them street after street to the river. Nothing was cut. One pot, everybody eligible, and the short stack takes the lot — every chip the deep players put in after they were already all-in, money that could never have been lost to them. All-in for 100 against two players who each put in 500, and the best hand collects 1,100 instead of the 300 it was playing for. Chip conservation never saw it. The chips balance perfectly; they just land in the wrong seat. And every browser session went through runout(), because a player shoving is what ends the betting. It took reading the code. Also from the review: play() dereferenced a table it had just been handed as null, the top-up button offered chips the wallet could not cover, and the trainer's ETA was sixty thousand hands optimistic on the first line it printed. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
417 lines
12 KiB
Go
417 lines
12 KiB
Go
package holdem
|
|
|
|
import (
|
|
_ "embed"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/rand/v2"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"pete/internal/games/cards"
|
|
)
|
|
|
|
// The bots' brain.
|
|
//
|
|
// gogobee ran counterfactual regret minimisation against this game for a very
|
|
// long time, and policy.gob is what it converged on: a table from "the situation
|
|
// I am in" to "how often I fold, call, raise small, raise big, or shove". It is
|
|
// the single highest-value thing in either repository, and none of it is
|
|
// re-derived here — this file is the *runtime*, the trainer stayed behind.
|
|
//
|
|
// A situation is squeezed down to six things, and this is the whole reason the
|
|
// table fits in memory: the street, whether the bot is in position, which of
|
|
// twelve equity buckets its hand falls in, which of five stack-to-pot buckets,
|
|
// whether the board is dry, wet or paired, and the last six actions. Two hands
|
|
// that hash to the same key get the same strategy, and that is the approximation
|
|
// the whole thing is built on.
|
|
//
|
|
// The key has to be *exactly* the key the trainer wrote, character for
|
|
// character. Change the bucket edges, the position label or the history encoding
|
|
// and every lookup misses — silently, because a miss is not an error, it is a
|
|
// fall back to the pot-odds rule below. The bots would get quietly, unaccountably
|
|
// worse. So: don't touch these numbers.
|
|
|
|
//go:embed policy.gob
|
|
var policyGob []byte
|
|
|
|
// The five things the trainer let a bot consider.
|
|
const (
|
|
actFold = iota
|
|
actCallCheck
|
|
actRaiseHalf
|
|
actRaisePot
|
|
actAllIn
|
|
numActions
|
|
)
|
|
|
|
// policyTable maps an info-set key to how often to take each action.
|
|
type policyTable map[string][numActions]float64
|
|
|
|
var (
|
|
policyOnce sync.Once
|
|
policy policyTable
|
|
)
|
|
|
|
// loadPolicy decodes the embedded table, once, on the first hand anybody plays.
|
|
//
|
|
// Not in an init(): it is megabytes of gob, and Pete is a news server that mostly
|
|
// never deals a card. Every test in the repo and every cold start would pay for
|
|
// it. The first player to sit down pays for it instead, and only they do.
|
|
func loadPolicy() policyTable {
|
|
policyOnce.Do(func() {
|
|
t, err := loadTrained(policyGob)
|
|
if err != nil {
|
|
// The bots still play — on pot odds — rather than the table 500ing.
|
|
slog.Error("holdem: cannot decode CFR policy, bots fall back to pot odds", "err", err)
|
|
policy = policyTable{}
|
|
return
|
|
}
|
|
policy = t.Strategy
|
|
slog.Info("holdem: CFR policy loaded", "nodes", len(policy),
|
|
"iterations", t.Meta.Iterations, "stakes", t.Meta.Stakes, "depths", t.Meta.Depths)
|
|
})
|
|
return policy
|
|
}
|
|
|
|
// ---- the info-set key ------------------------------------------------------
|
|
//
|
|
// Every function below is a load-bearing copy of the trainer's. See the note at
|
|
// the top of the file before changing a number in any of them.
|
|
|
|
// equityBucket puts a hand's strength in one of twelve boxes, from trash to
|
|
// monster.
|
|
func equityBucket(eq float64) int {
|
|
switch {
|
|
case eq < 0.08:
|
|
return 0
|
|
case eq < 0.17:
|
|
return 1
|
|
case eq < 0.25:
|
|
return 2
|
|
case eq < 0.33:
|
|
return 3
|
|
case eq < 0.42:
|
|
return 4
|
|
case eq < 0.50:
|
|
return 5
|
|
case eq < 0.58:
|
|
return 6
|
|
case eq < 0.67:
|
|
return 7
|
|
case eq < 0.75:
|
|
return 8
|
|
case eq < 0.83:
|
|
return 9
|
|
case eq < 0.92:
|
|
return 10
|
|
default:
|
|
return 11
|
|
}
|
|
}
|
|
|
|
// sprBucket is the stack-to-pot ratio: how much room is left to play. Under 1 is
|
|
// a pot that is already committed; over 12 is a pot you can still fold out of.
|
|
func sprBucket(spr float64) int {
|
|
switch {
|
|
case spr < 1:
|
|
return 0
|
|
case spr < 3:
|
|
return 1
|
|
case spr < 6:
|
|
return 2
|
|
case spr < 12:
|
|
return 3
|
|
default:
|
|
return 4
|
|
}
|
|
}
|
|
|
|
// Board textures, which is what makes the same hand a bet or a check.
|
|
const (
|
|
boardDry = 0
|
|
boardWet = 1
|
|
boardPaired = 2
|
|
)
|
|
|
|
// boardTexture classifies the community cards. A paired board is one somebody
|
|
// might have trips on; a wet one has a flush or straight coming.
|
|
// It is on the trainer's hot path — millions of calls — so it counts into arrays
|
|
// rather than maps. A map here cost more than the poker did.
|
|
func boardTexture(board []cards.Card) int {
|
|
if len(board) < 3 {
|
|
return boardDry
|
|
}
|
|
|
|
var ranks [14]int8
|
|
var suits [4]int8
|
|
var vals [5]int
|
|
for i, c := range board {
|
|
ranks[c.Rank]++
|
|
suits[c.Suit]++
|
|
vals[i] = int(c.Rank)
|
|
}
|
|
for _, n := range ranks {
|
|
if n >= 2 {
|
|
return boardPaired
|
|
}
|
|
}
|
|
for _, n := range suits {
|
|
if n >= 3 {
|
|
return boardWet
|
|
}
|
|
}
|
|
|
|
// Three cards inside a five-rank window is a straight waiting to happen.
|
|
v := vals[:len(board)]
|
|
for i := 1; i < len(v); i++ {
|
|
for j := i; j > 0 && v[j] < v[j-1]; j-- {
|
|
v[j], v[j-1] = v[j-1], v[j]
|
|
}
|
|
}
|
|
for i := 0; i+2 < len(v); i++ {
|
|
if v[i+2]-v[i] <= 4 {
|
|
return boardWet
|
|
}
|
|
}
|
|
return boardDry
|
|
}
|
|
|
|
// infoSet builds the string the policy is keyed on. The format is the trainer's.
|
|
//
|
|
// Position is IP or OOP — in position or out of it — and *nothing else*. This is
|
|
// the one thing gogobee got wrong, and it got it wrong invisibly for as long as
|
|
// the game has existed: the trainer packed a single "am I last to act" bit and
|
|
// wrote its keys as IP/OOP, while the runtime looked them up with the table
|
|
// labels a player would recognise (BTN, SB, BB, UTG…). Not one key ever matched.
|
|
// Every bot in every hand of hold'em gogobee ever dealt fell through to the
|
|
// pot-odds rule, and the five million training iterations sitting in policy.gob
|
|
// were never once read.
|
|
//
|
|
// Nothing about that looks broken from the outside. A missing key is not an
|
|
// error, it's a fallback — the bots played, they just played a heuristic. This is
|
|
// why the hit rate is now a test.
|
|
func infoSet(street Street, inPosition bool, eq, spr, texture int, history string) string {
|
|
pos := "OOP"
|
|
if inPosition {
|
|
pos = "IP"
|
|
}
|
|
return fmt.Sprintf("%d|%s|%d|%d|%d|%s", street, pos, eq, spr, texture, history)
|
|
}
|
|
|
|
// recent keeps the last six actions, which is all the trainer's key had room for.
|
|
func recent(h string) string {
|
|
if len(h) > 6 {
|
|
return h[len(h)-6:]
|
|
}
|
|
return h
|
|
}
|
|
|
|
// ---- where a seat stands ---------------------------------------------------
|
|
|
|
// mcIters is how many runouts a bot samples to judge its hand at the table.
|
|
const mcIters = 1000
|
|
|
|
// spot is everything the policy knows about a seat's situation, and it is the
|
|
// one function that builds it.
|
|
//
|
|
// The trainer calls this too. That is the point of it: the key the policy is
|
|
// written under and the key it is read under come out of the same code, so they
|
|
// cannot quietly stop matching — which is exactly what went wrong the first time
|
|
// and went unnoticed for the life of the game.
|
|
func (s State) spot(seat, iters int, rng *rand.Rand) (string, Equity) {
|
|
eq := s.equityFor(seat, iters, rng)
|
|
return s.spotKey(seat, eq), eq
|
|
}
|
|
|
|
// equityFor measures how good a seat's hand is right now.
|
|
//
|
|
// It depends only on the cards — the hand, the board, how many opponents — and
|
|
// not on a single thing that happened in the betting. Which is why the trainer
|
|
// can measure it once per street and reuse it down every branch it explores, and
|
|
// why doing that is most of the difference between a run that takes half an hour
|
|
// and one that takes four.
|
|
func (s State) equityFor(seat, iters int, rng *rand.Rand) Equity {
|
|
opponents := s.liveCount() - 1
|
|
if opponents < 1 {
|
|
opponents = 1
|
|
}
|
|
// Preflop heads-up is a lookup, not a simulation: there are only 169 hands
|
|
// that differ, they have been measured to death, and a sampled answer would
|
|
// only add noise to a bucket boundary.
|
|
if s.Street == PreFlop && opponents == 1 {
|
|
return preflopEquity(s.Seats[seat].Hole)
|
|
}
|
|
return equityOf(s.Seats[seat].Hole, s.Community, opponents, iters, rng)
|
|
}
|
|
|
|
// spotKey builds the key from an equity already measured.
|
|
func (s State) spotKey(seat int, eq Equity) string {
|
|
pot := s.inPlay()
|
|
spr := 0.0
|
|
if pot > 0 {
|
|
spr = float64(s.Seats[seat].Stack) / float64(pot)
|
|
}
|
|
return infoSet(s.Street, s.InPosition(seat), equityBucket(eq.Strength()),
|
|
sprBucket(spr), boardTexture(s.Community), recent(s.History))
|
|
}
|
|
|
|
// ---- choosing --------------------------------------------------------------
|
|
|
|
// hits and misses count how often a bot finds itself in the trained policy.
|
|
//
|
|
// They exist because the way this can break is silently. A key the policy has
|
|
// never seen is not an error — the bot shrugs and plays pot odds — so a policy
|
|
// that has stopped matching the game looks exactly like a policy that is working.
|
|
// gogobee's never matched once, for the whole life of the game, and nobody could
|
|
// have known by watching it play. Now a test reads these and fails.
|
|
var hits, misses atomic.Int64
|
|
|
|
// botActs plays one bot's turn: work out where it stands, look up what it does
|
|
// there, throw out anything illegal, and roll for it.
|
|
func (s *State) botActs(seat int, evs *[]Event, rng *rand.Rand) {
|
|
key, eq := s.spot(seat, mcIters, rng)
|
|
|
|
probs, ok := loadPolicy()[key]
|
|
if ok {
|
|
hits.Add(1)
|
|
} else {
|
|
misses.Add(1)
|
|
probs = potOdds(eq, s, seat)
|
|
}
|
|
probs = legal(probs, s, seat)
|
|
|
|
move := s.moveFor(pick(probs, rng), seat)
|
|
if err := s.act(seat, move, evs); err != nil {
|
|
// A bot cannot be allowed to wedge the table by choosing something the rules
|
|
// then refuse: it checks if it can and folds if it can't, and the mismatch
|
|
// is loud, because it means legal() and the betting rules disagree.
|
|
slog.Error("holdem: bot chose an illegal move", "seat", seat, "move", move.Kind, "err", err)
|
|
if s.Owed(seat) > 0 {
|
|
s.fold(seat, evs)
|
|
} else {
|
|
_ = s.check(seat, evs)
|
|
}
|
|
}
|
|
}
|
|
|
|
// potOdds is what a bot does when the trained table has never seen this spot: it
|
|
// works out whether the price it is being offered beats its chance of winning,
|
|
// and mixes in enough aggression not to be a calling station.
|
|
func potOdds(eq Equity, s *State, seat int) [numActions]float64 {
|
|
p := &s.Seats[seat]
|
|
strength := eq.Strength()
|
|
owed := s.Bet - p.Bet
|
|
pot := s.inPlay()
|
|
|
|
price := 0.0
|
|
if owed > 0 && pot+owed > 0 {
|
|
price = float64(owed) / float64(pot+owed)
|
|
}
|
|
|
|
var probs [numActions]float64
|
|
switch {
|
|
case strength > 0.8:
|
|
probs[actRaisePot], probs[actAllIn], probs[actCallCheck] = 0.6, 0.2, 0.2
|
|
case strength > 0.6:
|
|
probs[actRaiseHalf], probs[actCallCheck], probs[actFold] = 0.4, 0.5, 0.1
|
|
case owed > 0 && strength > price:
|
|
probs[actCallCheck], probs[actRaiseHalf], probs[actFold] = 0.7, 0.2, 0.1
|
|
case owed > 0:
|
|
probs[actFold], probs[actCallCheck] = 0.7, 0.3
|
|
default:
|
|
probs[actCallCheck], probs[actRaiseHalf], probs[actFold] = 0.6, 0.3, 0.1
|
|
}
|
|
return probs
|
|
}
|
|
|
|
// mask is what a seat may actually do here. The trainer explores exactly this
|
|
// set, so it never learns a strategy the table would turn down.
|
|
func (s State) mask(seat int) (m [numActions]bool) {
|
|
owed := s.Bet - s.Seats[seat].Bet
|
|
|
|
// Folding a hand you could see for free is a bug, not a strategy.
|
|
m[actFold] = owed > 0
|
|
m[actCallCheck] = true
|
|
|
|
// A raise needs chips behind the call, and somebody left to bet into.
|
|
raise := s.Seats[seat].Stack > owed && s.canBet()
|
|
m[actRaiseHalf], m[actRaisePot], m[actAllIn] = raise, raise, raise
|
|
return m
|
|
}
|
|
|
|
// legal zeroes out what the seat cannot do and renormalises what's left.
|
|
func legal(probs [numActions]float64, s *State, seat int) [numActions]float64 {
|
|
m := s.mask(seat)
|
|
var total float64
|
|
for i := range probs {
|
|
if !m[i] {
|
|
probs[i] = 0
|
|
}
|
|
total += probs[i]
|
|
}
|
|
if total <= 0 {
|
|
var only [numActions]float64
|
|
only[actCallCheck] = 1
|
|
return only
|
|
}
|
|
for i := range probs {
|
|
probs[i] /= total
|
|
}
|
|
return probs
|
|
}
|
|
|
|
// pick rolls against the distribution.
|
|
func pick(probs [numActions]float64, rng *rand.Rand) int {
|
|
r := rng.Float64()
|
|
sum := 0.0
|
|
for i, p := range probs {
|
|
sum += p
|
|
if r < sum {
|
|
return i
|
|
}
|
|
}
|
|
return actCallCheck
|
|
}
|
|
|
|
// moveFor turns an abstract action — "raise half the pot" — into a legal move at
|
|
// the actual size the table is at. A raise that would cost the bot everything it
|
|
// has is a shove, which is the same decision made honestly.
|
|
func (s *State) moveFor(action, seat int) Move {
|
|
p := &s.Seats[seat]
|
|
owed := s.Bet - p.Bet
|
|
pot := s.inPlay()
|
|
most := p.Bet + p.Stack
|
|
|
|
sized := func(by int64) Move {
|
|
if by < s.MinRaise {
|
|
by = s.MinRaise
|
|
}
|
|
to := s.Bet + by
|
|
if to >= most {
|
|
return Move{Kind: Shove}
|
|
}
|
|
return Move{Kind: Raise, To: to}
|
|
}
|
|
|
|
switch action {
|
|
case actFold:
|
|
if owed <= 0 {
|
|
return Move{Kind: Check} // never fold for free
|
|
}
|
|
return Move{Kind: Fold}
|
|
case actCallCheck:
|
|
if owed > 0 {
|
|
return Move{Kind: Call}
|
|
}
|
|
return Move{Kind: Check}
|
|
case actRaiseHalf:
|
|
return sized(pot / 2)
|
|
case actRaisePot:
|
|
return sized(pot)
|
|
case actAllIn:
|
|
return Move{Kind: Shove}
|
|
}
|
|
return Move{Kind: Check}
|
|
}
|