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
642 lines
20 KiB
Go
642 lines
20 KiB
Go
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
|
||
}
|