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) } } // A covered all-in player can only ever win what they matched, and the hand does // not have to end in a run-out for that to be true. // // This one got through everything. The side pots were only ever cut in runout(), // which happens when the betting stops because *nobody* can bet — so a short stack // who shoves and gets called by two players who still have chips behind, and who // then keep betting past them all the way to the river, reached a showdown with the // pots never cut. One pot, everybody eligible, and the short stack takes the lot. // // Chip conservation never saw it: the chips balance perfectly, they just land in // the wrong seat. And every browser session went through runout(), because the // player shoving is what ends the betting. It took reading the code. func TestACoveredAllInCannotWinTheSidePot(t *testing.T) { c := func(r cards.Rank, s cards.Suit) cards.Card { return cards.Card{Rank: r, Suit: s} } s := State{ Tier: Tiers[1], Phase: PhaseBetting, Street: River, Community: []cards.Card{ c(2, cards.Clubs), c(7, cards.Diamonds), c(9, cards.Spades), c(cards.Jack, cards.Hearts), c(4, cards.Clubs), }, Seats: []Seat{ {Name: "You", Stack: 500, Hole: [2]cards.Card{c(3, cards.Clubs), c(5, cards.Diamonds)}}, // All-in for 100, and holding the best hand at the table. {Name: "Short", Bot: true, Hole: [2]cards.Card{c(cards.Ace, cards.Clubs), c(cards.Ace, cards.Diamonds)}}, {Name: "Deep", Bot: true, Stack: 500, Hole: [2]cards.Card{c(cards.King, cards.Clubs), c(cards.Queen, cards.Diamonds)}}, }, } s.Seats[0].Total, s.Seats[0].State = 500, Active s.Seats[1].Total, s.Seats[1].State = 100, AllIn s.Seats[2].Total, s.Seats[2].State = 500, Active s.Pot = 1100 // 100 + 500 + 500 var evs []Event s.showdown(&evs) // The main pot is 100 from each of the three. The other 800 is between the two // who were still betting, and the short stack cannot touch it. if s.Seats[1].Won != 300 { t.Errorf("all-in for 100 against two players, and won %d — the most that can ever "+ "be won is the 300 main pot. The side pot was not cut.", s.Seats[1].Won) } if s.Seats[0].Won+s.Seats[2].Won != 800 { t.Errorf("the 800 side pot paid out %d between the two players who were "+ "actually contesting it", s.Seats[0].Won+s.Seats[2].Won) } } 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") } } // The house only makes money off you. A pot a bot wins is raked — that is what a // pot is — but the chips it comes out of are not real, so it has cost you nothing // and the number the felt quotes you must not move. func TestYouOnlyPayRakeOnPotsYouWin(t *testing.T) { s := State{Tier: Tiers[1], Flopped: true, Seats: []Seat{{Name: "You"}, {Name: "Dice", Bot: true}}} var evs []Event // A bot takes it. s.payPot(Pot{Amount: 400, Eligible: []int{1}}, []ranked{{seat: 1}}, &evs) if s.Paid != 0 { t.Errorf("you paid %d in rake on a pot a bot won", s.Paid) } if s.Rake != 20 { t.Errorf("the table lifted %d off that pot, want 20 — the chips have to balance "+ "whoever won it", s.Rake) } // Now you take one. s.payPot(Pot{Amount: 400, Eligible: []int{You}}, []ranked{{seat: You}}, &evs) if s.Paid != 20 { t.Errorf("you paid %d in rake on a 400 pot you won, want 20", s.Paid) } if s.Rake != 40 { t.Errorf("the table has lifted %d in total, want 40", s.Rake) } // And a chop costs you half of it. s.Paid, s.Rake = 0, 0 s.payPot(Pot{Amount: 400, Eligible: []int{0, 1}}, []ranked{{seat: 0, rank: 9}, {seat: 1, rank: 9}}, &evs) if s.Paid != 10 { t.Errorf("you paid %d in rake on a chopped pot, want 10 — half the rake, "+ "because you won half the pot", s.Paid) } } 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) } } } } h, m := hits.Load(), misses.Load() if h+m < 100 { t.Fatalf("the bots only made %d decisions — this test isn't measuring anything", h+m) } rate := float64(h) / float64(h+m) 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, h, h+m) } t.Logf("heads-up policy hit rate: %.0f%% (%d of %d decisions)", rate*100, h, h+m) } // ---- 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 }