Phase C, the engine half: hold'em becomes multiway, and the redaction that was a bug-in-one-handler becomes the security boundary the plan warned it would. - const You is gone. A table is a list of seats and which are human is a per-seat property, not the fixed index zero. New(tier, []SeatConfig, ...) seats the ring; SoloSeats builds the old one-human-plus-bots shape the solo handler still opens. - ApplyMove(state, seat, move) — seat identity enters the engine in exactly one place; every helper below already worked on indices. The advance loop stops at any human (not just seat 0), so one request plays the bots and hands control back at whichever person is next to act. - deal() now emits every seat's hole cards. The engine cannot redact a stream it doesn't know the audience of, so it stops trying: the view layer builds each viewer's redacted copy. viewHoldem/viewHoldemEvents take a viewerSeat. - Rake attributed to Paid whenever a *human* wins, not just seat 0 — real house income is rake off any player's pot, and bot pots are house-vs-house. - Bust is per-seat: at a solo table it still ends the session (PhaseDone), at a shared one a busted human just goes Out and the table plays on. Tests, three ways, all green: - the solo suite unchanged as a regression guard (a test-local You=0 alias); - TestMultiwayChipsAreConserved — 100 games, two humans at seats 0 and 2, chips counted after every move, proving the reshape actually plays; - TestHoldemViewNeverLeaksAnotherSeatsCards — renders every seat's view and event stream at every street and greps for anyone else's cards. Mutation-tested: undo the redaction and it fails on the preflop deal. No handlers rewired yet — the solo path still calls New(SoloSeats(...)) and renders for seat 0, so nothing a player sees has changed. The table cutover is next. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
785 lines
26 KiB
Go
785 lines
26 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)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
}
|