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

View File

@@ -399,21 +399,106 @@ A multi-session build. This section is the handover; read it before anything els
remembering as a rule: **size a card by its vars, never by the box you put it
in.**
- **Hold'em, and it is a cash game.** *(2026-07-14. Built, tested, and driven in a
browser. The bots had to be retrained from scratch — see below, it is the whole
story of this phase.)*
- **You buy in, you play, you leave with what's in front of you.** This is the
only table in the casino that is a *session* rather than a game. Everywhere else
stakes once and pays a multiple; poker isn't that shape. So the live row lives
across hands, and chips cross the border exactly twice: once when you sit down
and once when you get up. In between every pot is inside the engine and storage
sees none of it. Three stakes (1/2, 5/10, 25/50), buy in for 20100 big blinds,
top up between hands, bust and the session simply ends.
- **The rake is a real cardroom's rake**: five percent of a pot that *sees a flop*,
capped at three big blinds. No flop, no drop — so folding your blind round after
round costs you the blinds and no fee. Still winnings-only in the sense that
matters: you pay it out of a pot you win, never out of a bet you lose.
- **The bots move inside ApplyMove**, as UNO's do, which is what keeps poker off a
socket. One request plays your action, every bot action behind it, and whatever
streets that finishes — so shoving all-in returns the flop, turn, river, showdown
and payout in a single response, as a script the felt plays back.
- **The CFR policy was a lie, twice over, and this is the part worth reading.**
§5 called it "the single highest-value asset in either repository". It was not
being used *at all*, and could not have been:
1. **The key never matched.** The trainer packed a single "am I last to act" bit
and wrote its keys as `IP`/`OOP`. The runtime looked them up with the labels a
player would recognise — `BTN`, `SB`, `BB`, `UTG`. Not one key ever hit, for
the entire life of the game in gogobee. Nothing looked broken: a policy miss is
not an error, it is a silent fall back to a pot-odds heuristic. The bots played;
they just never once read the five million iterations sitting in policy.gob.
2. **And it was the wrong game anyway.** `TrainCFR` opens with `stack0, stack1 = 20,
20` at 1/2 blinds — a **ten big blind** push-fold stack. 82% of its nodes are in
the "stack smaller than the pot" bucket. A 20100BB cash game lands almost
nowhere near it. Worse, the tree it trained on was not hold'em: a call always
ended the street (so no big-blind option and no check-check), turn order was
history-length parity, and the payoff was ±half the pot regardless of who had
put what in.
- **So the trainer was rewritten to play the real engine.** `internal/games/holdem/
train.go` + `cmd/holdem-train`. External-sampling MCCFR, every move applied through
`Step` — the same reducer the felt calls — so the blinds, the min-raise, street
completion, side pots and the money are the ones a player actually meets. The
stack depth is drawn fresh every hand across the whole 20100BB range, because
poker at twenty big blinds and poker at a hundred are different games and a bot
that only knows one folds into the other.
- **The key is built by one function, `State.spot`, called by both the trainer and
the table.** That is the entire fix for bug (1), and it is structural: they cannot
drift apart because there is only one of them. And because a miss is *still* silent,
`Hits`/`Misses` are counted at the point a bot looks itself up, and
`TestTheBotsAreActuallyTrained` fails if the heads-up hit rate drops under 60%. It
is 95%. Multiway degrades on purpose — the policy is heads-up, six-handed reuses it
as a documented approximation, and the rest falls through to pot odds.
- **Three money bugs, and the tests earned their keep.** `TestChipsAreConserved`
plays a hundred sessions of real hands and counts every chip after every move; it
caught an **uncalled bet that minted chips** (the rule skipped folded players when
working out what had been matched, so a river bet folded to came back *whole*,
including the part called on the flop). A Go var-init ordering trap made `deck52`
build from an empty conversion table, so every card was identical, every showdown
tied and **every bot believed it held exactly 50% equity** — package-level vars are
built before `init()` runs. And the browser found the third: **the rake was
silently zero**, because the tiers declared `RakePct: 5` meaning percent while
`New()` overwrites it with blackjack's `0.05` meaning a fraction, and the integer
arithmetic floored 5% of a hundredth of the pot to nothing. Every rake test built
its own `State` by hand and so never saw the number the table runs on. There is now
one that does.
- **Driven in a browser, 2026-07-14, and it holds up.** Sitting down took 500 off a
5,000 stack and put it on the table; a hand played out; getting up put 1,004 back
(money conserved to the chip). The raise slider, the pot/half-pot/max presets, a
shove that runs the whole board out in one response, and a reload mid-hand that
brings back the hand, the board, the pot, the street and the stack — all clean.
**Side pots and split pots balance**: a three-way all-in paid 758 + 757 + 920 out
of a 2,495 pot with 60 raked, and `paid + rake == pot` on every multi-winner hand
sampled. A bot's cards never cross the wire until a showdown, and a folded seat's
never do at all. Six-handed at 390px: no sideways overflow, nothing colliding.
Console silent.
- Two felt bugs only the browser could show. **`.pete-stack` is `position: absolute;
inset: 0`** — so the pot's chips, sharing a box with the number under them, painted
straight over it: the pot showed a chip and no total. The pile needs a box of its
own. And **a `.pete-spot` is 7rem across** because blackjack has exactly one of
them; six of those is most of a felt, so a seat's bet spot is less than half the
size, with chips scaled to match. The bet total hangs *below* the ring
(`.pete-spot-total`), which is the existing rule for exactly this reason.
### Next, in order
1. **Phase 4 — hold'em**, as below. It is the last game on the list, and the first
one where a hand has other people in it: the spot is a singleton and the chip
animations are tuned for one seat (see "still open" below).
2. **Deploy.** Hangman, solitaire, trivia and UNO are all played, and all four are
still sitting on main un-deployed — the live casino is blackjack and nothing else.
The server runs `StartTriviaBank`, so trivia's bank fills itself once the binary
is out there, but the first player to try a ladder in the first minute after a
deploy gets the 503.
1. **Deploy.** Hangman, solitaire, trivia, UNO and hold'em are all played and all
five are sitting on main un-deployed — the live casino is blackjack and nothing
else. The server runs `StartTriviaBank`, so trivia's bank fills itself once the
binary is out there, but the first player to try a ladder in the first minute
after a deploy gets the 503.
2. **No Mercy UNO.** The plan's header line has always promised "UNO (normal +
no-mercy)" and only normal was ever built. gogobee has the rules
(`uno_nomercy.go`: a 168-card deck, draw-stacking, elimination at 25 cards,
sudden-death point scoring). It is a *rules dial orthogonal to the table-size
tier*, so the lobby becomes 3 sizes × 2 rule sets — and **the multiples have to be
re-measured**, because the current 2.2×/2.9×/3.6× are priced off a measured
go-out-first rate (43/32/27%) that draw-stacking and mercy elimination change
completely. Shipping No Mercy on the regular tier's prices would misprice it.
Still open on the table itself, none of it blocking: **split** isn't implemented (the
engine has no move for it), the felt is roomy at desktop widths with only one seat on
it, and the chip animations are tuned for one player — a second seat would need the
spot to be per-seat rather than the singleton it is now.
Still open on hold'em, none of it blocking: the policy is **heads-up**, so a
six-handed table is an approximation of it (the hit rate falls from 95% to about 17%
at six seats, and the rest is pot odds) — a multiway policy would want its own
training run with more than two seats in the tree. Blackjack still has no **split**.
### How the browser half fits together
@@ -592,20 +677,31 @@ into `pete/internal/games/`, let them drift, no shared module.
| Game | Copy | Rewrite | Notes |
|---|---|---|---|
| **Hold'em** | ~2,700 LOC | the shell | The crown jewel. Take it all. |
| **Hold'em** | ~1,400 LOC | the shell + **the whole CFR trainer** | See the warning below. |
| **UNO** | ~1,400 LOC | the turn engine | Great primitives, unshippable engine. |
| **Hangman** | ~250 LOC | loading/persistence | Clean rune-safe state machine. |
| **Blackjack** | ~95 LOC | everything else | 95 lines is the entire core. |
| **Trivia** | ~80 LOC | everything else | **No question bank exists.** |
### Hold'em — take almost all of it
### Hold'em — take the poker, not the brain
> **This section was wrong, and it cost most of Phase 4. Read §0's hold'em entry before
> you believe any of it.** `data/policy.gob` is **not** "the single highest-value asset in
> either repo". It is a 10-big-blind push-fold policy, trained against a model of poker
> that is not poker (a call always ends the street), and *it was never once read* — the
> trainer wrote its keys under `IP`/`OOP` and the runtime looked them up under
> `BTN`/`SB`/`BB`, so every lookup in the history of the game missed and fell through to a
> pot-odds heuristic. Nothing about that is visible from the outside: a policy miss is not
> an error. **Retrain against your own engine.** Pete now does — `internal/games/holdem/
> train.go` plays every move through the real reducer, and both the trainer and the table
> build the info-set key with the same function so they cannot drift apart again.
Already mautrix-free, verified by import check:
- `holdem_cfr.go` (1,285) — full CFR trainer + NPC policy runtime, info-set packing into a
`uint64`, regret pruning, board-texture/SPR/equity bucketing. Plus the trained
`data/policy.gob` (3.4 MB) and `cmd/holdem-train`, `cmd/holdem-seed`. **This is the single
highest-value asset in either repo.**
- `holdem_cfr.go` (1,285) — CFR trainer + NPC policy runtime, info-set packing into a
`uint64`, regret pruning, board-texture/SPR/equity bucketing. The *bucketing* is worth
taking (equity, SPR, board texture). The trainer, the tree and the trained policy are
not: see the warning above.
- `holdem_equity.go` + `holdem_equity_range.go` (548) — Monte-Carlo equity, equity-vs-range,
draw/out detection. 100% pure, well tested.
- `holdem_betting.go` (383) — side pots, min-raise, all-in, street completion. The fiddly