Files
Pete/internal/games/holdem/equity.go
prosolis e6c1bd3b54 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
2026-07-14 09:08:59 -07:00

135 lines
3.4 KiB
Go

package holdem
import (
"math/rand/v2"
"github.com/chehsunliu/poker"
"pete/internal/games/cards"
)
// How good is this hand, really?
//
// A bot's decision starts here: deal the cards it cannot see, a thousand times,
// and count how often it wins. That number — plus the board's texture, the
// stack-to-pot ratio and the action so far — is the key it looks its trained
// strategy up under.
//
// Monte Carlo rather than exhaustive because exhaustive is 2.1 million river
// runouts against one opponent and rather more against five, and a thousand
// samples puts the estimate inside a percentage point or so. The bot does not
// need the fourth decimal place; it needs to know whether it is ahead.
// Equity is the fraction of runouts a hand wins, ties and loses against the
// given number of unknown opponents.
type Equity struct {
Win float64
Tie float64
Loss float64
}
// Strength collapses a result into the one number the policy is keyed on: a tie
// is worth half a win, because that is what half a pot is.
func (e Equity) Strength() float64 { return e.Win + e.Tie*0.5 }
// deck52 is the evaluator's whole deck, built once.
var deck52 = func() []poker.Card {
d := make([]poker.Card, 0, 52)
for s := cards.Spades; s <= cards.Clubs; s++ {
for r := cards.Ace; r <= cards.King; r++ {
d = append(d, pokerOf[s][r])
}
}
return d
}()
// equityOf runs the simulation. The RNG is threaded like everything else here,
// so a bot's decision replays from the session's seed along with the deal.
func equityOf(hole [2]cards.Card, board []cards.Card, opponents, iterations int, rng *rand.Rand) Equity {
if opponents < 1 {
opponents = 1
}
h0, h1 := toPoker(hole[0]), toPoker(hole[1])
// Seven cards, checked by hand. A map here would be the most expensive thing
// in the trainer, which calls this function millions of times.
var known [7]poker.Card
n := 2
known[0], known[1] = h0, h1
pb := make([]poker.Card, len(board))
for i, c := range board {
pb[i] = toPoker(c)
known[n] = pb[i]
n++
}
rest := make([]poker.Card, 0, 52)
for _, c := range deck52 {
seen := false
for _, k := range known[:n] {
if k == c {
seen = true
break
}
}
if !seen {
rest = append(rest, c)
}
}
need := opponents*2 + (5 - len(pb))
if need > len(rest) {
return Equity{Tie: 1}
}
hero := make([]poker.Card, 7)
hero[0], hero[1] = h0, h1
villain := make([]poker.Card, 7)
full := make([]poker.Card, 5)
var wins, ties int
for i := 0; i < iterations; i++ {
// A partial Fisher-Yates: only the cards actually needed get shuffled into
// place, which is the difference between this being cheap and being the
// slowest thing in the request.
for j := 0; j < need; j++ {
k := j + rng.IntN(len(rest)-j)
rest[j], rest[k] = rest[k], rest[j]
}
copy(full, pb)
at := opponents * 2
for b := len(pb); b < 5; b++ {
full[b] = rest[at]
at++
}
copy(hero[2:], full)
mine := poker.Evaluate(hero)
best := int32(7463) // one worse than the worst real hand
for o := 0; o < opponents; o++ {
villain[0], villain[1] = rest[o*2], rest[o*2+1]
copy(villain[2:], full)
if r := poker.Evaluate(villain); r < best {
best = r
}
}
switch {
case mine < best:
wins++
case mine == best:
ties++
}
}
total := float64(iterations)
return Equity{
Win: float64(wins) / total,
Tie: float64(ties) / total,
Loss: float64(iterations-wins-ties) / total,
}
}