Files
Pete/internal/games/holdem/holdem_test.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

642 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package holdem
import (
"math/rand/v2"
"testing"
"pete/internal/games/cards"
)
// The one that matters: no chip is ever created or destroyed.
//
// Everything else in this package is a rule you could argue about. This is the
// one that would lose somebody money. Every chip at the table is in exactly one
// place — a stack, a bet in front of a seat, a pot, or the house's rake — and
// the only thing that ever adds to the total is a bot reloading. So: play a
// hundred sessions of real hands, with the trained bots making real decisions,
// and count the chips after every single move.
func TestChipsAreConserved(t *testing.T) {
for game := 0; game < 100; game++ {
rng := rand.New(rand.NewPCG(uint64(game), 99))
bots := 1 + game%MaxBots
tier := Tiers[game%len(Tiers)]
s, _, err := New(tier, bots, tier.MaxBuy, tier.RakePct, uint64(game), 7)
if err != nil {
t.Fatalf("new table: %v", err)
}
want := chipsAt(s) // what the table started with
for hand := 0; hand < 8 && s.Phase != PhaseDone; hand++ {
var evs []Event
s, evs, err = ApplyMove(s, Move{Kind: Deal})
if err != nil {
t.Fatalf("game %d hand %d: deal: %v", game, hand, err)
}
want += reloaded(evs) // a bot that rebought brought new chips with it
check(t, s, want, game, hand, "deal")
for step := 0; s.Phase == PhaseBetting; step++ {
if step > 200 {
t.Fatalf("game %d hand %d: the hand will not end", game, hand)
}
s, _, err = ApplyMove(s, randomMove(s, rng))
if err != nil {
t.Fatalf("game %d hand %d: %v", game, hand, err)
}
check(t, s, want, game, hand, "move")
}
}
}
}
// chipsAt totals every chip the table can see, plus every one the house has
// already taken out of it.
func chipsAt(s State) int64 {
total := s.Rake + s.Pot
for _, p := range s.Seats {
total += p.Stack + p.Bet
}
for _, pot := range s.Side {
total += pot.Amount
}
return total
}
// reloaded is what the bots brought back to the table on this deal. It is the
// only thing in the game that is allowed to make chips out of nothing, which is
// exactly why the test has to know about it and nothing else does.
func reloaded(evs []Event) int64 {
var n int64
for _, e := range evs {
if e.Kind == "rebuy" {
n += e.Amount
}
}
return n
}
func check(t *testing.T, s State, want int64, game, hand int, when string) {
t.Helper()
if got := chipsAt(s); got != want {
t.Fatalf("game %d hand %d, after %s: %d chips on the table, want %d "+
"(pot %d, rake %d, stacks %v)", game, hand, when, got, want, s.Pot, s.Rake, stacks(s))
}
for i, p := range s.Seats {
if p.Stack < 0 {
t.Fatalf("game %d hand %d: seat %d has a negative stack (%d)", game, hand, i, p.Stack)
}
}
}
func stacks(s State) []int64 {
out := make([]int64, len(s.Seats))
for i, p := range s.Seats {
out[i] = p.Stack
}
return out
}
// randomMove picks something legal for the player, without any thought at all.
// A bad player is exactly what this test wants: it gets into all-ins, folds,
// short stacks and split pots far faster than a good one would.
func randomMove(s State, rng *rand.Rand) Move {
owed := s.Owed(You)
var legal []Move
if owed > 0 {
legal = append(legal, Move{Kind: Fold}, Move{Kind: Call})
} else {
legal = append(legal, Move{Kind: Check})
}
if s.Seats[You].Stack > owed && s.canBet() {
legal = append(legal, Move{Kind: Shove})
if to := s.MinRaiseTo(You); to < s.MaxRaiseTo(You) {
legal = append(legal, Move{Kind: Raise, To: to})
}
}
return legal[rng.IntN(len(legal))]
}
// ---- the rules a poker player would notice were wrong -----------------------
func TestHeadsUpButtonIsTheSmallBlindAndActsFirst(t *testing.T) {
s := table(t, Tiers[0], 1, 200)
s, evs, err := ApplyMove(s, Move{Kind: Deal})
if err != nil {
t.Fatal(err)
}
// The button posted the small blind, not the big one.
var small, big int
for _, e := range evs {
if e.Kind == "blind" && e.Text == "small" {
small = e.Seat
}
if e.Kind == "blind" && e.Text == "big" {
big = e.Seat
}
}
if small != s.Button {
t.Errorf("heads-up: seat %d posted the small blind, but the button is seat %d", small, s.Button)
}
if big == s.Button {
t.Error("heads-up: the button posted the big blind too")
}
// And it is the first to act before the flop. (If the button is a bot it has
// already acted, so what we can check is that the player didn't get skipped.)
if s.Phase != PhaseBetting {
t.Fatalf("phase %q: the hand should be waiting on somebody", s.Phase)
}
}
func TestTheBigBlindGetsTheirOption(t *testing.T) {
// A table where everyone just calls: the big blind has the bet matched without
// ever having chosen anything, and the street must not end until they speak.
s := table(t, Tiers[0], 1, 200)
s, _, _ = ApplyMove(s, Move{Kind: Deal})
// Find a hand where the player is the big blind. The button alternates, so at
// most a couple of deals.
for i := 0; i < 6 && s.Position(You) != "BB"; i++ {
s = playOut(t, s)
s, _, _ = ApplyMove(s, Move{Kind: Deal})
}
if s.Position(You) != "BB" {
t.Skip("never dealt the big blind")
}
if s.Phase != PhaseBetting {
return // the bot folded or raised; either way the option isn't the question
}
if s.ToAct == You && s.Owed(You) == 0 {
// This is the option: nothing to call, but the hand is still ours to act on.
if _, _, err := ApplyMove(s, Move{Kind: Check}); err != nil {
t.Errorf("the big blind cannot check their option: %v", err)
}
}
}
func TestAShortAllInDoesNotReopenTheBetting(t *testing.T) {
s := State{
Tier: Tiers[1], // 5/10
Seats: []Seat{{Name: "You", Stack: 1000}, {Name: "Bot", Bot: true, Stack: 1000}},
Bet: 100,
MinRaise: 100, // a full raise would be to 200
Aggressor: You,
Phase: PhaseBetting,
}
s.Seats[You].Bet = 100
s.Seats[1].Bet = 0
s.Seats[1].Stack = 150 // can only get to 150, which is a raise of 50: not a full one
var evs []Event
if err := s.allin(1, &evs); err != nil {
t.Fatal(err)
}
if s.Bet != 150 {
t.Errorf("the bet to call is %d, want 150", s.Bet)
}
if s.MinRaise != 100 {
t.Errorf("min raise moved to %d — a short all-in must not change it", s.MinRaise)
}
if s.Aggressor != You {
t.Errorf("the aggressor moved to seat %d — a short all-in must not reopen the action", s.Aggressor)
}
}
func TestSidePotsPayInLayers(t *testing.T) {
// Three players all-in for different amounts. The short stack can only win
// what everyone could have lost to them.
s := State{
Tier: Tiers[1],
Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}, {Name: "B", Bot: true}},
}
s.Seats[0].Total, s.Seats[0].State = 100, AllIn // short
s.Seats[1].Total, s.Seats[1].State = 500, AllIn // middle
s.Seats[2].Total, s.Seats[2].State = 500, AllIn // covers
s.sidePots()
if len(s.Side) != 2 {
t.Fatalf("got %d pots, want 2: %+v", len(s.Side), s.Side)
}
main, side := s.Side[0], s.Side[1]
if main.Amount != 300 { // 100 from each of the three
t.Errorf("main pot is %d, want 300", main.Amount)
}
if len(main.Eligible) != 3 {
t.Errorf("main pot has %d eligible, want all 3", len(main.Eligible))
}
if side.Amount != 800 { // 400 more from each of the two who had it
t.Errorf("side pot is %d, want 800", side.Amount)
}
if len(side.Eligible) != 2 {
t.Errorf("side pot has %d eligible, want 2 — the short stack cannot win it", len(side.Eligible))
}
if total := main.Amount + side.Amount; total != 1100 {
t.Errorf("the pots hold %d, but %d went in", total, 1100)
}
}
func TestFoldedChipsStayInThePotButWinNothing(t *testing.T) {
s := State{
Tier: Tiers[1],
Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}, {Name: "B", Bot: true}},
}
s.Seats[0].Total, s.Seats[0].State = 200, AllIn
s.Seats[1].Total, s.Seats[1].State = 50, Folded // called 50 and gave up
s.Seats[2].Total, s.Seats[2].State = 200, AllIn
s.sidePots()
var total int64
for _, p := range s.Side {
total += p.Amount
for _, seat := range p.Eligible {
if seat == 1 {
t.Error("a folded seat is eligible to win a pot")
}
}
}
if total != 450 {
t.Errorf("the pots hold %d, want 450 — the folder's 50 has to still be in there", total)
}
}
func TestAnUncalledBetComesBack(t *testing.T) {
s := State{
Tier: Tiers[1],
Seats: []Seat{{Name: "You", Stack: 0}, {Name: "A", Bot: true}},
}
s.Seats[0].Total, s.Seats[0].Bet, s.Seats[0].State = 500, 500, AllIn
s.Seats[1].Total, s.Seats[1].State = 200, Folded
var evs []Event
s.uncalled(&evs)
if s.Seats[You].Stack != 300 {
t.Errorf("got %d back, want the 300 nobody called", s.Seats[You].Stack)
}
if s.Seats[You].Total != 200 {
t.Errorf("still committed for %d, want 200 — the rest was never in the pot", s.Seats[You].Total)
}
if s.Seats[You].State != Active {
t.Error("still marked all-in for chips that came back")
}
}
// ---- the rake --------------------------------------------------------------
func TestNoFlopNoDrop(t *testing.T) {
s := State{Tier: Tiers[1], Flopped: false, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
var evs []Event
s.payPot(Pot{Amount: 1000, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
if s.Rake != 0 {
t.Errorf("raked %d off a pot that never saw a flop", s.Rake)
}
if s.Seats[You].Stack != 1000 {
t.Errorf("paid %d of a 1000 pot", s.Seats[You].Stack)
}
}
func TestTheRakeIsCapped(t *testing.T) {
s := State{Tier: Tiers[1], Flopped: true, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
var evs []Event
// 5% of 10,000 is 500, but the cap is three big blinds — 30 at 5/10.
s.payPot(Pot{Amount: 10000, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
want := s.Tier.BB * rakeCapBB
if s.Rake != want {
t.Errorf("raked %d, want the %d cap", s.Rake, want)
}
if s.Seats[You].Stack != 10000-want {
t.Errorf("paid %d, want %d", s.Seats[You].Stack, 10000-want)
}
}
func TestTheRakeIsFivePercentUnderTheCap(t *testing.T) {
s := State{Tier: Tiers[0], Flopped: true, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
var evs []Event
s.payPot(Pot{Amount: 100, Eligible: []int{You}}, []ranked{{seat: You}}, &evs) // cap is 6 at 1/2
if s.Rake != 5 {
t.Errorf("raked %d off a 100 pot, want 5", s.Rake)
}
if s.Seats[You].Stack != 95 {
t.Errorf("paid %d, want 95", s.Seats[You].Stack)
}
}
// The rake has to survive the wiring, not only the arithmetic.
//
// This is the test that was missing, and a browser found what it would have
// found: New() overwrites the tier's rake with the one the casino hands it, and
// the casino hands it a *fraction* (blackjack's 0.05). The tier declared 5,
// meaning percent. Every other rake test builds a State by hand and sets the tier
// itself, so not one of them ever saw the number a real table runs on — and the
// house quietly took nothing from every pot for an afternoon.
func TestTheRakeSurvivesTheConstructor(t *testing.T) {
tier := Tiers[1] // 5/10, so the cap is 30
s, _, err := New(tier, 1, tier.MaxBuy, 0.05, 1, 2)
if err != nil {
t.Fatal(err)
}
s.Flopped = true
var evs []Event
s.payPot(Pot{Amount: 400, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
if s.Rake != 20 {
t.Fatalf("the house took %d of a 400 pot, want 20 — five percent of it. "+
"RakePct is a fraction (0.05), not a percentage (5): see the note on Tiers.", s.Rake)
}
if !has(evs, "rake") {
t.Error("the rake was taken with no event to say so, so the felt cannot show it")
}
}
func TestASplitPotSplits(t *testing.T) {
s := State{Tier: Tiers[1], Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
var evs []Event
// Same rank: they chop. The odd chip goes to one of them, not into the air.
s.payPot(Pot{Amount: 101, Eligible: []int{0, 1}},
[]ranked{{seat: 0, rank: 500}, {seat: 1, rank: 500}}, &evs)
if got := s.Seats[0].Stack + s.Seats[1].Stack; got != 101 {
t.Errorf("paid out %d of a 101 pot", got)
}
if s.Seats[0].Stack != 51 || s.Seats[1].Stack != 50 {
t.Errorf("split %d/%d, want 51/50", s.Seats[0].Stack, s.Seats[1].Stack)
}
}
// ---- the session -----------------------------------------------------------
func TestYouCannotWalkOutOfALiveHand(t *testing.T) {
s := table(t, Tiers[0], 2, 200)
s, _, _ = ApplyMove(s, Move{Kind: Deal})
if s.Phase != PhaseBetting {
t.Skip("the hand ended before the player could act")
}
if _, _, err := ApplyMove(s, Move{Kind: Leave}); err != ErrHandLive {
t.Errorf("leaving mid-hand gave %v, want ErrHandLive", err)
}
if _, _, err := ApplyMove(s, Move{Kind: TopUp, Amount: 10}); err != ErrHandLive {
t.Errorf("topping up mid-hand gave %v, want ErrHandLive", err)
}
}
func TestLeavingTakesTheStackHome(t *testing.T) {
s := table(t, Tiers[0], 1, 200)
s, _, _ = ApplyMove(s, Move{Kind: Deal})
s = playOut(t, s)
stack := s.Seats[You].Stack
s, _, err := ApplyMove(s, Move{Kind: Leave})
if err != nil {
t.Fatal(err)
}
if s.Phase != PhaseDone {
t.Errorf("phase %q after leaving, want done", s.Phase)
}
if s.Payout != stack {
t.Errorf("payout %d, want the %d that was in front of us", s.Payout, stack)
}
if _, _, err := ApplyMove(s, Move{Kind: Deal}); err != ErrOver {
t.Errorf("dealt a hand at a table we got up from: %v", err)
}
}
func TestBustingEndsTheSession(t *testing.T) {
s := table(t, Tiers[0], 1, 200)
s.Seats[You].Stack = 0
var evs []Event
s.endHand(&evs)
if s.Phase != PhaseDone {
t.Errorf("phase %q with no chips left, want done", s.Phase)
}
if s.Payout != 0 {
t.Errorf("payout %d for a busted player", s.Payout)
}
if !has(evs, "bust") {
t.Error("no bust event")
}
}
func TestATopUpCannotGoOverTheTableMax(t *testing.T) {
s := table(t, Tiers[0], 1, 200) // max buy is 200, and we're at it
if _, _, err := ApplyMove(s, Move{Kind: TopUp, Amount: 1}); err != ErrBadBuyIn {
t.Errorf("topped up over the table maximum: %v", err)
}
s = table(t, Tiers[0], 1, 100)
s, _, err := ApplyMove(s, Move{Kind: TopUp, Amount: 50})
if err != nil {
t.Fatal(err)
}
if s.Seats[You].Stack != 150 {
t.Errorf("stack is %d, want 150", s.Seats[You].Stack)
}
if s.BoughtIn != 150 {
t.Errorf("bought in for %d, want 150 — the top-up is real money too", s.BoughtIn)
}
}
func TestABuyInHasToBeInRange(t *testing.T) {
tier := Tiers[0]
for _, amount := range []int64{0, tier.MinBuy - 1, tier.MaxBuy + 1} {
if _, _, err := New(tier, 1, amount, 5, 1, 2); err != ErrBadBuyIn {
t.Errorf("buy-in of %d at a %d%d table: %v", amount, tier.MinBuy, tier.MaxBuy, err)
}
}
}
// ---- what the player is allowed to know ------------------------------------
func TestTheScriptNeverCarriesABotsCards(t *testing.T) {
rng := rand.New(rand.NewPCG(4, 4))
s := table(t, Tiers[0], 3, 200)
for hand := 0; hand < 20 && s.Phase != PhaseDone; hand++ {
s, evs, err := ApplyMove(s, Move{Kind: Deal})
if err != nil {
t.Fatal(err)
}
noBotCards(t, s, evs)
for s.Phase == PhaseBetting {
var next []Event
s, next, err = ApplyMove(s, randomMove(s, rng))
if err != nil {
t.Fatal(err)
}
noBotCards(t, s, next)
}
}
}
// A bot's cards may appear in exactly one kind of event: the showdown that turns
// them face up, which is the moment they stop being secret.
func noBotCards(t *testing.T, s State, evs []Event) {
t.Helper()
for _, e := range evs {
if len(e.Cards) == 0 || e.Seat < 0 || e.Kind == "show" {
continue
}
if e.Seat != You && s.Seats[e.Seat].Bot {
t.Fatalf("a %q event carries seat %d's cards (%v) — that's a bot's hand",
e.Kind, e.Seat, e.Cards)
}
}
}
// ---- hand strength ---------------------------------------------------------
func TestTheEvaluatorKnowsWhichHandIsBetter(t *testing.T) {
board := []cards.Card{
{Rank: 10, Suit: cards.Hearts}, {Rank: cards.Jack, Suit: cards.Hearts},
{Rank: cards.Queen, Suit: cards.Hearts}, {Rank: 2, Suit: cards.Spades},
{Rank: 7, Suit: cards.Clubs},
}
flush, _ := rankOf([2]cards.Card{{Rank: 3, Suit: cards.Hearts}, {Rank: 5, Suit: cards.Hearts}}, board)
straight, _ := rankOf([2]cards.Card{{Rank: cards.King, Suit: cards.Spades}, {Rank: cards.Ace, Suit: cards.Clubs}}, board)
royal, _ := rankOf([2]cards.Card{{Rank: cards.King, Suit: cards.Hearts}, {Rank: cards.Ace, Suit: cards.Hearts}}, board)
pair, _ := rankOf([2]cards.Card{{Rank: 7, Suit: cards.Spades}, {Rank: 4, Suit: cards.Diamonds}}, board)
if !(royal < flush && flush < straight && straight < pair) {
t.Errorf("hands rank royal=%d flush=%d straight=%d pair=%d — lower must be better, in that order",
royal, flush, straight, pair)
}
}
func TestEquityKnowsAcesAreGood(t *testing.T) {
rng := rand.New(rand.NewPCG(1, 1))
aces := equityOf([2]cards.Card{{Rank: cards.Ace, Suit: cards.Spades}, {Rank: cards.Ace, Suit: cards.Hearts}}, nil, 1, 2000, rng)
rags := equityOf([2]cards.Card{{Rank: 7, Suit: cards.Spades}, {Rank: 2, Suit: cards.Hearts}}, nil, 1, 2000, rng)
if aces.Strength() < 0.8 {
t.Errorf("pocket aces are worth %.2f heads-up, want about 0.85", aces.Strength())
}
if rags.Strength() > 0.4 {
t.Errorf("seven-deuce is worth %.2f heads-up, want about 0.35", rags.Strength())
}
if aces.Strength() <= rags.Strength() {
t.Error("seven-deuce is not better than pocket aces")
}
}
// The policy loads, and every node in it is a probability distribution.
func TestThePolicyLoads(t *testing.T) {
p := loadPolicy()
if len(p) < 1000 {
t.Fatalf("the CFR policy has %d nodes in it — it did not load, or it was never trained", len(p))
}
for key, probs := range p {
var sum float64
for _, v := range probs {
if v < 0 {
t.Fatalf("%s: a negative probability (%v)", key, probs)
}
sum += v
}
if sum < 0.99 || sum > 1.01 {
t.Fatalf("%s: the probabilities sum to %v, not 1", key, sum)
}
}
}
// TestTheBotsAreActuallyTrained is the test this game most needed and did not
// have.
//
// A bot that cannot find itself in the policy does not fail. It shrugs, plays the
// pot-odds rule, and looks exactly like a bot that is working — which is how
// gogobee shipped a trained poker AI whose policy was *never read once* for the
// entire life of the game. The trainer wrote its keys under IP/OOP and the table
// looked them up under BTN/SB/BB, and there was nothing anywhere that would have
// said so.
//
// So: deal real hands, let the bots think, and count how often the thinking lands
// in the table. Heads-up is the number that has to hold — that is what the policy
// was trained on. A six-handed table is a documented approximation of it and
// drops off as seats are added, which is why this only asserts on the duel.
func TestTheBotsAreActuallyTrained(t *testing.T) {
Hits.Store(0)
Misses.Store(0)
rng := rand.New(rand.NewPCG(11, 12))
for game := 0; game < 40; game++ {
tier := Tiers[1]
s, _, err := New(tier, 1, tier.MaxBuy, tier.RakePct, uint64(game), 5)
if err != nil {
t.Fatal(err)
}
for hand := 0; hand < 6 && s.Phase != PhaseDone; hand++ {
s, _, err = ApplyMove(s, Move{Kind: Deal})
if err != nil {
t.Fatal(err)
}
for s.Phase == PhaseBetting {
s, _, err = ApplyMove(s, randomMove(s, rng))
if err != nil {
t.Fatal(err)
}
}
}
}
hits, misses := Hits.Load(), Misses.Load()
if hits+misses < 100 {
t.Fatalf("the bots only made %d decisions — this test isn't measuring anything", hits+misses)
}
rate := float64(hits) / float64(hits+misses)
if rate < 0.6 {
t.Fatalf("heads-up, the bots found themselves in the trained policy %.0f%% of the time "+
"(%d of %d decisions). They are playing the pot-odds fallback, which means the key the "+
"trainer writes and the key the table reads have drifted apart. See infoSet.",
rate*100, hits, hits+misses)
}
t.Logf("heads-up policy hit rate: %.0f%% (%d of %d decisions)", rate*100, hits, hits+misses)
}
// ---- helpers ---------------------------------------------------------------
func table(t *testing.T, tier Tier, bots int, buyIn int64) State {
t.Helper()
s, _, err := New(tier, bots, buyIn, tier.RakePct, 1, 2)
if err != nil {
t.Fatalf("new table: %v", err)
}
return s
}
// playOut folds every decision until the hand is over.
func playOut(t *testing.T, s State) State {
t.Helper()
for i := 0; s.Phase == PhaseBetting; i++ {
if i > 100 {
t.Fatal("the hand will not end")
}
move := Move{Kind: Fold}
if s.Owed(You) == 0 {
move = Move{Kind: Check}
}
var err error
s, _, err = ApplyMove(s, move)
if err != nil {
t.Fatalf("playing out: %v", err)
}
}
return s
}
func has(evs []Event, kind string) bool {
for _, e := range evs {
if e.Kind == kind {
return true
}
}
return false
}