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
240 lines
7.7 KiB
Go
240 lines
7.7 KiB
Go
package web
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"pete/internal/games/holdem"
|
||
"pete/internal/storage"
|
||
)
|
||
|
||
// Sitting down is the only time chips leave your stack at this table, and getting
|
||
// up is the only time they come back. Everything in between is inside the engine.
|
||
func TestHoldemSitTakesTheBuyInAndNothingElse(t *testing.T) {
|
||
s := newCasino(t)
|
||
fund(t, 5000)
|
||
|
||
v, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||
map[string]any{"tier": "low", "bots": 2, "buyin": 600}))
|
||
if code != 200 {
|
||
t.Fatalf("sit = %d, want 200", code)
|
||
}
|
||
if v.Chips != 4400 {
|
||
t.Fatalf("chips after a 600 buy-in = %d, want 4400", v.Chips)
|
||
}
|
||
if v.Game != gameHoldem || v.Holdem == nil {
|
||
t.Fatalf("sit returned no table: game=%q", v.Game)
|
||
}
|
||
|
||
g := v.Holdem
|
||
if g.Stack != 600 {
|
||
t.Errorf("you sat down with %d, want the 600 you bought in for", g.Stack)
|
||
}
|
||
if len(g.Seats) != 3 {
|
||
t.Fatalf("two bots and you is three seats, got %d", len(g.Seats))
|
||
}
|
||
if g.Phase != "handover" {
|
||
t.Errorf("phase %q — a table you just sat at has no hand on it yet", g.Phase)
|
||
}
|
||
|
||
// Play a whole hand, then check that not one chip has crossed the border.
|
||
deal, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||
map[string]any{"move": "deal"}))
|
||
if code != 200 {
|
||
t.Fatalf("deal = %d, want 200", code)
|
||
}
|
||
for i := 0; deal.Holdem != nil && deal.Holdem.Phase == "betting" && i < 60; i++ {
|
||
move := "check"
|
||
if deal.Holdem.Owed > 0 {
|
||
move = "fold"
|
||
}
|
||
deal, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||
map[string]any{"move": move}))
|
||
if code != 200 {
|
||
t.Fatalf("%s = %d, want 200", move, code)
|
||
}
|
||
}
|
||
if deal.Chips != 4400 {
|
||
t.Errorf("chips moved during a hand: %d, want the 4400 that were left after the buy-in — "+
|
||
"a pot is settled inside the engine, not across the border", deal.Chips)
|
||
}
|
||
}
|
||
|
||
// The wall. A bot's hole cards are the game; a browser that held them would make
|
||
// this unplayable, and the payload is where that has to be true.
|
||
func TestHoldemNeverSendsABotsCards(t *testing.T) {
|
||
s := newCasino(t)
|
||
fund(t, 5000)
|
||
|
||
v, _ := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||
map[string]any{"tier": "micro", "bots": 3, "buyin": 200}))
|
||
if v.Holdem == nil {
|
||
t.Fatal("no table")
|
||
}
|
||
|
||
// Play hands until one of them ends without a showdown, checking every payload
|
||
// on the way. Folding is the case that matters: nobody has earned the right to
|
||
// see anybody's cards, so nobody's may be in there.
|
||
for hand := 0; hand < 8; hand++ {
|
||
v, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||
map[string]any{"move": "deal"}))
|
||
if code != 200 {
|
||
t.Fatalf("deal = %d", code)
|
||
}
|
||
noBotCards(t, v)
|
||
|
||
for i := 0; v.Holdem != nil && v.Holdem.Phase == "betting" && i < 60; i++ {
|
||
move := "check"
|
||
if v.Holdem.Owed > 0 {
|
||
move = "call"
|
||
}
|
||
v, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||
map[string]any{"move": move}))
|
||
if code != 200 {
|
||
t.Fatalf("%s = %d", move, code)
|
||
}
|
||
noBotCards(t, v)
|
||
}
|
||
if v.Holdem == nil || v.Holdem.Phase == "done" {
|
||
return // busted out; the wall held all the way
|
||
}
|
||
}
|
||
}
|
||
|
||
// noBotCards checks a payload. A bot's cards may appear in exactly one place: a
|
||
// seat that is being shown down, on a board that reached a showdown.
|
||
func noBotCards(t *testing.T, v tableView) {
|
||
t.Helper()
|
||
g := v.Holdem
|
||
if g == nil {
|
||
return
|
||
}
|
||
shown := g.Street == "showdown"
|
||
for i, seat := range g.Seats {
|
||
if i == 0 || len(seat.Cards) == 0 {
|
||
continue
|
||
}
|
||
if !shown {
|
||
t.Fatalf("seat %d (%s) sent %d cards on the %s — nobody has shown down",
|
||
i, seat.Name, len(seat.Cards), g.Street)
|
||
}
|
||
if seat.State == "folded" {
|
||
t.Fatalf("seat %d (%s) folded and its cards were sent anyway", i, seat.Name)
|
||
}
|
||
}
|
||
for _, e := range v.HoldemEvents {
|
||
if e.Seat > 0 && len(e.Cards) > 0 && e.Kind != "show" {
|
||
t.Fatalf("a %q event carries seat %d's cards — that's a bot's hand", e.Kind, e.Seat)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Getting up is what pays. Everything the session did lands in one movement.
|
||
func TestHoldemLeavingBringsTheStackBack(t *testing.T) {
|
||
s := newCasino(t)
|
||
fund(t, 5000)
|
||
|
||
v, _ := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||
map[string]any{"tier": "low", "bots": 1, "buyin": 500}))
|
||
if v.Chips != 4500 || v.Holdem == nil {
|
||
t.Fatalf("sit: chips=%d holdem=%v", v.Chips, v.Holdem)
|
||
}
|
||
stack := v.Holdem.Stack
|
||
|
||
v, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||
map[string]any{"move": "leave"}))
|
||
if code != 200 {
|
||
t.Fatalf("leave = %d, want 200", code)
|
||
}
|
||
if v.Chips != 4500+stack {
|
||
t.Errorf("chips after getting up = %d, want %d (the %d that was in front of us)",
|
||
v.Chips, 4500+stack, stack)
|
||
}
|
||
if v.Game != "" {
|
||
t.Errorf("still at a table after getting up: %q", v.Game)
|
||
}
|
||
|
||
// And there is no table left to play at.
|
||
_, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||
map[string]any{"move": "deal"}))
|
||
if code != 409 {
|
||
t.Errorf("dealt a hand at a table we got up from: %d, want 409", code)
|
||
}
|
||
}
|
||
|
||
// You cannot walk out on a hand you have chips riding on.
|
||
func TestHoldemCannotLeaveMidHand(t *testing.T) {
|
||
s := newCasino(t)
|
||
fund(t, 5000)
|
||
|
||
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
|
||
v, _ := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||
map[string]any{"move": "deal"}))
|
||
if v.Holdem == nil || v.Holdem.Phase != "betting" {
|
||
t.Skip("the hand ended before we could act")
|
||
}
|
||
|
||
_, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||
map[string]any{"move": "leave"}))
|
||
if code != 400 {
|
||
t.Errorf("left in the middle of a hand: %d, want 400", code)
|
||
}
|
||
|
||
// And the chips are still on the table, not back on the stack.
|
||
after, err := storage.Chips("@reala:parodia.dev")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if after.Chips != 4500 {
|
||
t.Errorf("chips = %d, want 4500 — the buy-in is still on the table", after.Chips)
|
||
}
|
||
}
|
||
|
||
// A buy-in outside the table's range is not a buy-in, and it must not take chips.
|
||
func TestHoldemRefusesABadBuyIn(t *testing.T) {
|
||
s := newCasino(t)
|
||
fund(t, 5000)
|
||
|
||
tier, _ := holdem.TierBySlug("low")
|
||
for _, amount := range []int64{tier.MinBuy - 1, tier.MaxBuy + 1, 0} {
|
||
_, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||
map[string]any{"tier": "low", "bots": 2, "buyin": amount}))
|
||
if code != 400 {
|
||
t.Errorf("buy-in of %d at a %d–%d table = %d, want 400", amount, tier.MinBuy, tier.MaxBuy, code)
|
||
}
|
||
}
|
||
|
||
st, err := storage.Chips("@reala:parodia.dev")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if st.Chips != 5000 {
|
||
t.Errorf("a refused buy-in took %d chips", 5000-st.Chips)
|
||
}
|
||
}
|
||
|
||
// A top-up is real money crossing the border. If the engine refuses it, the chips
|
||
// come straight back — the same order, and the same reason, as doubling down.
|
||
func TestHoldemTopUpRefundsWhenRefused(t *testing.T) {
|
||
s := newCasino(t)
|
||
fund(t, 5000)
|
||
|
||
// Sitting down at the maximum means there is no room to top up into.
|
||
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||
map[string]any{"tier": "low", "bots": 1, "buyin": 1000}))
|
||
|
||
_, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||
map[string]any{"move": "topup", "amount": 100}))
|
||
if code != 400 {
|
||
t.Errorf("topped up over the table maximum: %d, want 400", code)
|
||
}
|
||
|
||
st, err := storage.Chips("@reala:parodia.dev")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if st.Chips != 4000 {
|
||
t.Errorf("chips = %d, want 4000 — a refused top-up must give the chips back", st.Chips)
|
||
}
|
||
}
|