The two-browser pass found it: at a table two humans share, the felt quoted each of them the pair's total. "Bought in for 200" to a player who put in 100, and a session-rake line that climbed on a pot the other one won. Both were table totals the view read straight off the engine — correct while a table had one human, wrong the moment it had two. Fixed along the border it already draws: bought_in is border accounting, so it comes from the viewer's own game_seats.staked; session rake is a within-table event, so it rides a new per-seat Seat.Paid beside the audit's table-total s.Paid. And top-up never grew game_seats.staked, so the storage invariant drifted by every top-up and the felt under-reported the buy-in — it does now. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
819 lines
28 KiB
Go
819 lines
28 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, SoloSeats(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 = apply(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 = apply(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
|
||
}
|
||
|
||
// You is seat zero — the shape every test in this file is written against. The
|
||
// engine no longer has this constant: a table is a list of seats and which are
|
||
// human is a per-seat property, not a fixed index. But these tests all seat one
|
||
// human at zero (the pre-multiplayer solo shape), so a test-local alias keeps
|
||
// them readable as the regression guard they are. The multiway behaviour has its
|
||
// own tests, which do not assume it.
|
||
const You = 0
|
||
|
||
// apply plays a move as the seat whose turn it is at a solo table — always the
|
||
// human at seat zero. It wraps the seat-parameterized ApplyMove so the solo tests
|
||
// read as they did before the reshape.
|
||
func apply(s State, m Move) (State, []Event, error) {
|
||
return ApplyMove(s, You, m)
|
||
}
|
||
|
||
// 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 := apply(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, _, _ = apply(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, _, _ = apply(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 := apply(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, SoloSeats(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)
|
||
}
|
||
}
|
||
|
||
// At a table with two humans the session-rake line each is shown is their own,
|
||
// not the table's: the house took it out of a pot one of them won, and quoting it
|
||
// to the other says the house has taken money off them that it has not. So the
|
||
// per-seat tally only ever moves on the seat that won the pot.
|
||
func TestRakeIsChargedToTheSeatThatWonIt(t *testing.T) {
|
||
s := State{Tier: Tiers[1], Flopped: true,
|
||
Seats: []Seat{{Name: "Reala"}, {Name: "Bob"}}}
|
||
var evs []Event
|
||
|
||
// Reala wins a 400 pot. The rake on it (5%, 20) is Reala's to have paid.
|
||
s.payPot(Pot{Amount: 400, Eligible: []int{0}}, []ranked{{seat: 0}}, &evs)
|
||
if s.Seats[0].Paid != 20 {
|
||
t.Errorf("the seat that won the pot paid %d in rake, want 20", s.Seats[0].Paid)
|
||
}
|
||
if s.Seats[1].Paid != 0 {
|
||
t.Errorf("the other player was charged %d in rake for a pot they were not in", s.Seats[1].Paid)
|
||
}
|
||
if s.Paid != 20 {
|
||
t.Errorf("the table total is %d, want 20 — it is still the sum for the audit", s.Paid)
|
||
}
|
||
|
||
// Bob wins the next one. His tally moves; Reala's stays where it was.
|
||
s.payPot(Pot{Amount: 200, Eligible: []int{1}}, []ranked{{seat: 1}}, &evs)
|
||
if s.Seats[0].Paid != 20 {
|
||
t.Errorf("Reala's tally moved on a pot Bob won: %d, want 20", s.Seats[0].Paid)
|
||
}
|
||
if s.Seats[1].Paid != 10 {
|
||
t.Errorf("Bob paid %d in rake on a 200 pot he won, want 10", s.Seats[1].Paid)
|
||
}
|
||
if s.Paid != 30 {
|
||
t.Errorf("the table total is %d, want 30", 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, _, _ = apply(s, Move{Kind: Deal})
|
||
if s.Phase != PhaseBetting {
|
||
t.Skip("the hand ended before the player could act")
|
||
}
|
||
if _, _, err := apply(s, Move{Kind: Leave}); err != ErrHandLive {
|
||
t.Errorf("leaving mid-hand gave %v, want ErrHandLive", err)
|
||
}
|
||
if _, _, err := apply(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, _, _ = apply(s, Move{Kind: Deal})
|
||
s = playOut(t, s)
|
||
|
||
stack := s.Seats[You].Stack
|
||
s, _, err := apply(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 := apply(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 := apply(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 := apply(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, SoloSeats(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 raw script carries ------------------------------------------
|
||
|
||
// The engine no longer redacts the event stream, and this pins why: a shared
|
||
// table has more than one human, so the engine cannot know who a stream is for.
|
||
// It emits every seat's hole cards, and the *view* layer builds each viewer's
|
||
// redacted copy — that per-seat redaction is the security boundary now, and it
|
||
// has its own test in the web package (TestHoldemViewNeverLeaksAnotherSeatsCards).
|
||
//
|
||
// So the engine-level contract flipped: the deal must carry a hole event for
|
||
// every dealt seat, or a viewer would have no cards of their own to be shown.
|
||
func TestTheDealScriptCarriesEverySeatsHole(t *testing.T) {
|
||
s := table(t, Tiers[0], 3, 200) // four seats: one human, three bots
|
||
s, evs, err := apply(s, Move{Kind: Deal})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
holes := map[int][]cards.Card{}
|
||
for _, e := range evs {
|
||
if e.Kind == "hole" {
|
||
holes[e.Seat] = e.Cards
|
||
}
|
||
}
|
||
for i := range s.Seats {
|
||
if s.Seats[i].State == Out {
|
||
continue
|
||
}
|
||
got := holes[i]
|
||
if len(got) != 2 {
|
||
t.Fatalf("seat %d got no hole event; the view has nothing to redact from", i)
|
||
}
|
||
if got[0] != s.Seats[i].Hole[0] || got[1] != s.Seats[i].Hole[1] {
|
||
t.Errorf("seat %d hole event %v disagrees with state %v", i, got, s.Seats[i].Hole)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- 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, SoloSeats(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 = apply(s, Move{Kind: Deal})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for s.Phase == PhaseBetting {
|
||
s, _, err = apply(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, SoloSeats(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 = apply(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
|
||
}
|
||
|
||
// Where you sit is decided when the button moves, and a fold does not move it.
|
||
// Position walked the table with nextIn, which steps over folded seats while the
|
||
// seat count still includes them — so as players mucked, the labels slid round
|
||
// and a six-handed felt printed CO on three different seats at once. The badge is
|
||
// the only thing that reads this, which is exactly why nothing caught it.
|
||
func TestPositionsDoNotMoveWhenSeatsFold(t *testing.T) {
|
||
s := table(t, Tiers[0], 5, 200) // six-handed
|
||
s, _, _ = apply(s, Move{Kind: Deal})
|
||
|
||
before := make([]string, len(s.Seats))
|
||
for i := range s.Seats {
|
||
before[i] = s.Position(i)
|
||
}
|
||
|
||
// Every seat has its own label, and the ones a six-max table prints are these.
|
||
seen := map[string]int{}
|
||
for _, p := range before {
|
||
seen[p]++
|
||
}
|
||
for _, want := range []string{"BTN", "SB", "BB", "UTG", "MP", "CO"} {
|
||
if seen[want] != 1 {
|
||
t.Errorf("six-handed: %q appears %d times, want exactly once — got %v",
|
||
want, seen[want], before)
|
||
}
|
||
}
|
||
|
||
// Now fold seats out of the hand, one at a time. Nobody's position changes by
|
||
// mucking — folding is done to the state directly because a fold in the engine
|
||
// belongs to whoever is to act, and what is under test is the label, not the turn.
|
||
for i := range s.Seats {
|
||
if i == You || s.Seats[i].State != Active {
|
||
continue
|
||
}
|
||
s.Seats[i].State = Folded
|
||
for j := range s.Seats {
|
||
if got := s.Position(j); got != before[j] {
|
||
t.Fatalf("seat %d was %q and is now %q after seat %d folded — position is "+
|
||
"where you sit, not who is left", j, before[j], got, i)
|
||
}
|
||
}
|
||
}
|
||
}
|