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
432 lines
12 KiB
Go
432 lines
12 KiB
Go
package holdem
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/gob"
|
||
"fmt"
|
||
"io"
|
||
"math/rand/v2"
|
||
"sync"
|
||
|
||
"pete/internal/games/cards"
|
||
)
|
||
|
||
// The trainer.
|
||
//
|
||
// This is counterfactual regret minimisation, and what it produces is policy.gob
|
||
// — the table the bots read at the table. It is not on any request path; it runs
|
||
// from cmd/holdem-train, for half an hour, and then it is a file.
|
||
//
|
||
// The one thing worth understanding about it: **it plays the real game.** Every
|
||
// move it explores goes through Step, which is the same reducer the felt calls,
|
||
// so the blinds, the min-raise, the street completion and the money are the ones
|
||
// a player will actually meet. Its info-set key comes out of State.spot, which is
|
||
// the same function the bots look themselves up with.
|
||
//
|
||
// That is not tidiness, it is the whole lesson of the policy this replaces. That
|
||
// one was trained against a hand-written model of poker sitting beside the real
|
||
// engine — a model where a call always ended the street, the big blind had no
|
||
// option, and the payoff was half the pot no matter who had put what in. Then it
|
||
// was looked up under a key the trainer never wrote. The result was a 3.4MB file
|
||
// that had never once been read, and nobody could tell, because a policy miss is
|
||
// not an error. It just quietly isn't there.
|
||
//
|
||
// So: one engine, one key function, and a test that fails if the bots stop
|
||
// finding themselves in the table.
|
||
|
||
// How much of the game tree to explore. Two raises a street keeps the tree small
|
||
// enough to converge; a third barely changes how anybody plays and multiplies the
|
||
// nodes.
|
||
const (
|
||
maxRaisesPerStreet = 2
|
||
maxDepth = 40
|
||
trainMCIters = 60 // noisy, but it is only picking a bucket
|
||
)
|
||
|
||
// regrets is what CFR accumulates: how much better each action would have been.
|
||
type regrets map[string]*[numActions]float64
|
||
|
||
// Trained is the file the bots read.
|
||
type Trained struct {
|
||
Strategy map[string][numActions]float64
|
||
Meta TrainMeta
|
||
}
|
||
|
||
// TrainMeta is what the policy can say about itself. Worth having: a policy is
|
||
// otherwise an opaque three megabytes and there is no way to tell a good one from
|
||
// a stale one by looking.
|
||
type TrainMeta struct {
|
||
Iterations int
|
||
Stakes string
|
||
Depths string
|
||
Nodes int
|
||
}
|
||
|
||
// ---- preflop, measured once ------------------------------------------------
|
||
|
||
var (
|
||
preflopOnce sync.Once
|
||
preflopTable [13][13]Equity // [hi][lo] offsuit, [lo][hi] suited, diagonal pairs
|
||
)
|
||
|
||
// preflopEquity is the equity of a starting hand heads-up. There are only 169
|
||
// hands that differ from each other, so they are measured properly, once, and
|
||
// then it is a lookup — which matters twice: it takes the noise out of a bucket
|
||
// boundary, and the trainer visits preflop on every single iteration.
|
||
func preflopEquity(hole [2]cards.Card) Equity {
|
||
preflopOnce.Do(func() {
|
||
rng := cards.NewRNG(20260714, 1)
|
||
for a := cards.Ace; a <= cards.King; a++ {
|
||
for b := a; b <= cards.King; b++ {
|
||
lo, hi := rankIdx(a), rankIdx(b)
|
||
|
||
// Suited, and the pairs (which can only be offsuit) on the diagonal.
|
||
s1 := cards.Card{Rank: a, Suit: cards.Spades}
|
||
s2 := cards.Card{Rank: b, Suit: cards.Spades}
|
||
if a == b {
|
||
s2.Suit = cards.Hearts
|
||
}
|
||
preflopTable[lo][hi] = equityOf([2]cards.Card{s1, s2}, nil, 1, 10000, rng)
|
||
|
||
if a != b {
|
||
o2 := cards.Card{Rank: b, Suit: cards.Hearts}
|
||
preflopTable[hi][lo] = equityOf([2]cards.Card{s1, o2}, nil, 1, 10000, rng)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
lo, hi := rankIdx(hole[0].Rank), rankIdx(hole[1].Rank)
|
||
if lo > hi {
|
||
lo, hi = hi, lo
|
||
}
|
||
if hole[0].Suit == hole[1].Suit {
|
||
return preflopTable[lo][hi] // suited, and the pairs sit here too
|
||
}
|
||
if lo == hi {
|
||
return preflopTable[lo][hi]
|
||
}
|
||
return preflopTable[hi][lo] // offsuit
|
||
}
|
||
|
||
// rankIdx maps a rank to 0–12, with the ace high — which is what it is, before
|
||
// the flop.
|
||
func rankIdx(r cards.Rank) int {
|
||
if r == cards.Ace {
|
||
return 12
|
||
}
|
||
return int(r) - 2
|
||
}
|
||
|
||
// ---- the traversal ---------------------------------------------------------
|
||
|
||
// Train runs external-sampling MCCFR for n hands and returns the average
|
||
// strategy. Each worker keeps its own tables and they are summed at the end,
|
||
// which is what makes this embarrassingly parallel and is the only reason it
|
||
// finishes in half an hour.
|
||
func Train(n, workers int, t Tier, minBB, maxBB int64, seed uint64, progress func(done int)) *Trained {
|
||
if workers < 1 {
|
||
workers = 1
|
||
}
|
||
|
||
type table struct {
|
||
reg regrets
|
||
avg regrets
|
||
}
|
||
out := make([]table, workers)
|
||
|
||
var wg sync.WaitGroup
|
||
var done sync.Mutex
|
||
completed := 0
|
||
|
||
for w := 0; w < workers; w++ {
|
||
wg.Add(1)
|
||
go func(w int) {
|
||
defer wg.Done()
|
||
tr := &trainer{
|
||
reg: regrets{},
|
||
avg: regrets{},
|
||
tier: t,
|
||
minBB: minBB,
|
||
maxBB: maxBB,
|
||
rng: cards.NewRNG(seed, uint64(w)+1),
|
||
}
|
||
|
||
share := n / workers
|
||
if w < n%workers {
|
||
share++
|
||
}
|
||
for i := 0; i < share; i++ {
|
||
tr.iterate(uint64(w)<<40 | uint64(i))
|
||
// i+1, not i: the check fired on the very first pass and credited two
|
||
// thousand hands before a single one had been walked, which with thirty
|
||
// workers made the first ETA sixty thousand hands optimistic.
|
||
if progress != nil && (i+1)%2000 == 0 {
|
||
done.Lock()
|
||
completed += 2000
|
||
c := completed
|
||
done.Unlock()
|
||
progress(c)
|
||
}
|
||
}
|
||
out[w] = table{tr.reg, tr.avg}
|
||
}(w)
|
||
}
|
||
wg.Wait()
|
||
|
||
// Sum the workers' average-strategy tables, then normalise each node into the
|
||
// probabilities a bot will actually play.
|
||
total := regrets{}
|
||
for _, tab := range out {
|
||
for key, v := range tab.avg {
|
||
acc, ok := total[key]
|
||
if !ok {
|
||
acc = &[numActions]float64{}
|
||
total[key] = acc
|
||
}
|
||
for i, x := range v {
|
||
acc[i] += x
|
||
}
|
||
}
|
||
}
|
||
|
||
strategy := make(map[string][numActions]float64, len(total))
|
||
for key, v := range total {
|
||
var sum float64
|
||
for _, x := range v {
|
||
sum += x
|
||
}
|
||
var probs [numActions]float64
|
||
if sum > 0 {
|
||
for i, x := range v {
|
||
probs[i] = x / sum
|
||
}
|
||
} else {
|
||
for i := range probs {
|
||
probs[i] = 1.0 / numActions
|
||
}
|
||
}
|
||
strategy[key] = probs
|
||
}
|
||
|
||
return &Trained{
|
||
Strategy: strategy,
|
||
Meta: TrainMeta{
|
||
Iterations: n,
|
||
Stakes: fmt.Sprintf("%d/%d", t.SB, t.BB),
|
||
Depths: fmt.Sprintf("%d–%d BB", minBB, maxBB),
|
||
Nodes: len(strategy),
|
||
},
|
||
}
|
||
}
|
||
|
||
type trainer struct {
|
||
reg regrets
|
||
avg regrets
|
||
tier Tier
|
||
minBB int64
|
||
maxBB int64
|
||
rng *rand.Rand
|
||
|
||
// A hand's equity on a given street depends on the cards and nothing else —
|
||
// not on how the betting went to get there. The deck is fixed for the whole
|
||
// iteration, so the flop is the same flop down every branch, and this is
|
||
// measured once per seat per street instead of once per node.
|
||
eq [2][4]Equity
|
||
have [2][4]bool
|
||
}
|
||
|
||
// iterate deals one hand and walks it once for each player.
|
||
//
|
||
// The stack depth is drawn fresh every hand, across the whole range the table
|
||
// allows. This is the fix for the policy that came before: it was trained at ten
|
||
// big blinds and nothing else, so four out of five spots in a real cash game fell
|
||
// outside anything it had ever seen. A hand of poker is a different game at 20
|
||
// big blinds than at 100 — that is most of what makes it a game — and the bots
|
||
// have to have played both.
|
||
func (tr *trainer) iterate(id uint64) {
|
||
depth := tr.minBB
|
||
if tr.maxBB > tr.minBB {
|
||
depth += tr.rng.Int64N(tr.maxBB - tr.minBB + 1)
|
||
}
|
||
stack := depth * tr.tier.BB
|
||
|
||
// No rake while learning. The bots should learn to play poker, not to beat a
|
||
// fee, and the fee is the house's business.
|
||
t := tr.tier
|
||
t.RakePct = 0
|
||
|
||
s, err := open(t, stack, stack, id, tr.rng.Uint64())
|
||
if err != nil {
|
||
return
|
||
}
|
||
start := [2]int64{s.Seats[0].Stack + s.Seats[0].Bet, s.Seats[1].Stack + s.Seats[1].Bet}
|
||
|
||
tr.have = [2][4]bool{} // one deal, one set of boards, one set of equities
|
||
|
||
for me := 0; me < 2; me++ {
|
||
tr.walk(s.clone(), me, start, 0)
|
||
}
|
||
}
|
||
|
||
// equity is the cached measurement for this seat on this street.
|
||
func (tr *trainer) equity(s State, seat int) Equity {
|
||
st := s.Street
|
||
if st > River {
|
||
st = River
|
||
}
|
||
if !tr.have[seat][st] {
|
||
tr.eq[seat][st] = s.equityFor(seat, trainMCIters, tr.rng)
|
||
tr.have[seat][st] = true
|
||
}
|
||
return tr.eq[seat][st]
|
||
}
|
||
|
||
// walk returns what the hand is worth to `me`, in chips, from here.
|
||
func (tr *trainer) walk(s State, me int, start [2]int64, depth int) float64 {
|
||
if s.Phase != PhaseBetting || depth > maxDepth {
|
||
// The hand is over (or we have gone far enough to call it over). What it was
|
||
// worth is simply what the player has now against what they sat down with —
|
||
// the real number, out of the real engine, side pots and all.
|
||
return float64(s.Seats[me].Stack - start[me])
|
||
}
|
||
|
||
seat := s.ToAct
|
||
key := s.spotKey(seat, tr.equity(s, seat))
|
||
|
||
mask := s.mask(seat)
|
||
if raises(s.History) >= maxRaisesPerStreet {
|
||
mask[actRaiseHalf], mask[actRaisePot] = false, false
|
||
}
|
||
|
||
reg := tr.reg[key]
|
||
if reg == nil {
|
||
reg = &[numActions]float64{}
|
||
tr.reg[key] = reg
|
||
}
|
||
strat := match(*reg, mask)
|
||
|
||
// The opponent's turn: sample one line and follow it. That is the "external
|
||
// sampling" part, and it is what keeps a hand from costing 5^12 traversals.
|
||
if seat != me {
|
||
avg := tr.avg[key]
|
||
if avg == nil {
|
||
avg = &[numActions]float64{}
|
||
tr.avg[key] = avg
|
||
}
|
||
for i, p := range strat {
|
||
avg[i] += p
|
||
}
|
||
return tr.walk(tr.play(s, seat, sample(strat, tr.rng)), me, start, depth+1)
|
||
}
|
||
|
||
// Our turn: try everything, and regret what we didn't do.
|
||
var values [numActions]float64
|
||
var node float64
|
||
for a := 0; a < numActions; a++ {
|
||
if !mask[a] {
|
||
continue
|
||
}
|
||
values[a] = tr.walk(tr.play(s, seat, a), me, start, depth+1)
|
||
node += strat[a] * values[a]
|
||
}
|
||
for a := 0; a < numActions; a++ {
|
||
if mask[a] {
|
||
reg[a] += values[a] - node
|
||
}
|
||
}
|
||
return node
|
||
}
|
||
|
||
// play applies one abstract action through the real reducer.
|
||
func (tr *trainer) play(s State, seat, action int) State {
|
||
next, _, err := step(s.clone(), s.moveFor(action, seat))
|
||
if err != nil {
|
||
// The mask and the rules disagreed, which is a bug in one of them. Fold and
|
||
// carry on rather than poison the whole run.
|
||
next, _, err = step(s.clone(), Move{Kind: Fold})
|
||
if err != nil {
|
||
return s
|
||
}
|
||
}
|
||
return next
|
||
}
|
||
|
||
// match is regret matching: play each action in proportion to how much you wish
|
||
// you had played it. An action nobody regrets not taking gets played uniformly.
|
||
func match(reg [numActions]float64, mask [numActions]bool) [numActions]float64 {
|
||
var strat [numActions]float64
|
||
var sum float64
|
||
for i, r := range reg {
|
||
if mask[i] && r > 0 {
|
||
sum += r
|
||
}
|
||
}
|
||
if sum > 0 {
|
||
for i, r := range reg {
|
||
if mask[i] && r > 0 {
|
||
strat[i] = r / sum
|
||
}
|
||
}
|
||
return strat
|
||
}
|
||
|
||
n := 0
|
||
for _, ok := range mask {
|
||
if ok {
|
||
n++
|
||
}
|
||
}
|
||
if n == 0 {
|
||
strat[actCallCheck] = 1
|
||
return strat
|
||
}
|
||
for i, ok := range mask {
|
||
if ok {
|
||
strat[i] = 1 / float64(n)
|
||
}
|
||
}
|
||
return strat
|
||
}
|
||
|
||
func sample(strat [numActions]float64, rng *rand.Rand) int {
|
||
r := rng.Float64()
|
||
var sum float64
|
||
for i, p := range strat {
|
||
sum += p
|
||
if r < sum {
|
||
return i
|
||
}
|
||
}
|
||
return actCallCheck
|
||
}
|
||
|
||
// raises counts the bets and raises on this street, which is what the tree is
|
||
// capped on.
|
||
func raises(history string) int {
|
||
n := 0
|
||
for _, c := range history {
|
||
if c == 'r' || c == 'R' {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// ---- the file --------------------------------------------------------------
|
||
|
||
// Save writes a trained policy.
|
||
func Save(w io.Writer, t *Trained) error { return gob.NewEncoder(w).Encode(t) }
|
||
|
||
// Load reads one. It is only used by the tests — the bots read the embedded copy.
|
||
func Load(r io.Reader) (*Trained, error) {
|
||
var t Trained
|
||
if err := gob.NewDecoder(r).Decode(&t); err != nil {
|
||
return nil, err
|
||
}
|
||
return &t, nil
|
||
}
|
||
|
||
// loadTrained decodes the embedded policy in the new format.
|
||
func loadTrained(b []byte) (*Trained, error) { return Load(bytes.NewReader(b)) }
|