games: the poker engine learns there can be more than one of you

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
This commit is contained in:
prosolis
2026-07-14 16:05:19 -07:00
parent 004fca3f25
commit 5b381b03ff
8 changed files with 485 additions and 131 deletions

View File

@@ -180,13 +180,13 @@ func (s *State) payPot(pot Pot, live []ranked, evs *[]Event) {
amount -= rake
s.Rake += rake // every chip of it, so the table still balances
// But only the part that came out of *your* winnings is money the house
// actually made, and it is the only part the felt should quote you. The
// bots' chips are not real — the only real money at this table is yours —
// so raking a pot a bot won costs you nothing, and a counter that climbed
// while you folded would be telling you it had.
// But only the part that came out of a *human's* winnings is money the
// house actually made, and it is the only part worth quoting. The bots'
// chips are not real — the only real money at the table is the players'
// so raking a pot a bot won costs nobody anything, and a counter that
// climbed while every human folded would be telling them it had.
for _, w := range winners {
if w.seat == You {
if !s.Seats[w.seat].Bot {
s.Paid += rake / int64(len(winners))
}
}

View File

@@ -51,9 +51,6 @@ var (
ErrTableFull = errors.New("holdem: too many seats")
)
// You are always seat zero. The bots are the seats after you.
const You = 0
// rakeCapBB caps the rake on any one pot at three big blinds, which is what a
// cardroom does. Without a cap, five percent of a big pot is a lot of money to
// take off a player for winning it.
@@ -264,46 +261,86 @@ type Move struct {
// botNames are the regulars. Six of them so a full table never has two.
var botNames = []string{"Dice", "Marjorie", "Ox", "Sunny", "Pinch", "The Reverend"}
// New sits you down. The buy-in is chips the caller has already taken off the
// player's stack; this engine only ever gives them back through Leave.
// SeatConfig is one chair the table opens with. A shared table is seated by the
// runtime: humans in the chairs people have taken, bots in the rest. Solo play is
// just the case where exactly one chair is human, which is why there is no longer
// a separate solo constructor and no seat-zero-is-you convention — a table is a
// list of seats, and who is human is a property of each seat, not of its index.
type SeatConfig struct {
Name string
Bot bool
// Stack is the starting chips: a human's buy-in (already taken off their chip
// stack by the caller), or a bot's stake from the house.
Stack int64
}
// New opens a table and seats it. No hand is dealt yet — the table opens on
// PhaseHandOver, the state a table between hands is in, and the first Deal starts
// the first hand.
//
// No hand is dealt yet — the table opens on PhaseHandOver, which is the state a
// table between hands is in, and the first Deal move starts the first hand.
func New(t Tier, bots int, buyIn int64, rakePct float64, seed1, seed2 uint64) (State, []Event, error) {
if bots < 1 || bots > MaxBots {
// The engine gives a human's chips back only by the runtime reading their stack
// when they leave; it never credits a chip stack itself. Bot stacks are house
// chips and not real money — the only real money at the table is the humans'.
func New(t Tier, seats []SeatConfig, rakePct float64, seed1, seed2 uint64) (State, []Event, error) {
if len(seats) < 2 || len(seats) > MaxBots+1 {
return State{}, nil, ErrTableFull
}
if buyIn < t.MinBuy || buyIn > t.MaxBuy {
return State{}, nil, ErrBadBuyIn
}
t.RakePct = rakePct
s := State{
Tier: t,
Button: 0,
Phase: PhaseHandOver,
BoughtIn: buyIn,
Seed1: seed1,
Seed2: seed2,
Tier: t,
Phase: PhaseHandOver,
Seed1: seed1,
Seed2: seed2,
}
s.Seats = append(s.Seats, Seat{Name: "You", Stack: buyIn})
for i := 0; i < bots; i++ {
s.Seats = append(s.Seats, Seat{Name: botNames[i], Bot: true, Stack: t.MaxBuy})
var evs []Event
for _, sc := range seats {
if !sc.Bot && (sc.Stack < t.MinBuy || sc.Stack > t.MaxBuy) {
return State{}, nil, ErrBadBuyIn
}
i := len(s.Seats)
s.Seats = append(s.Seats, Seat{Name: sc.Name, Bot: sc.Bot, Stack: sc.Stack})
if !sc.Bot {
// BoughtIn is the sum of what the humans brought — the audit's idea of the
// stake. Per-seat border accounting lives in storage (game_seats.staked),
// not here; the engine only ever moves chips within the table.
s.BoughtIn += sc.Stack
evs = append(evs, Event{Kind: "sit", Seat: i, Amount: sc.Stack, Text: t.Name})
}
}
// The button starts to your right, so the first hand deals you the small blind
// heads-up and the button on a full table — either way you are in the action
// from the first card rather than folding your way in.
// The button starts at the last seat, so deal() moves it to the first: a full
// table puts the earliest seat on the button, and heads-up puts it on the small
// blind, in the action from the first card either way.
s.Button = len(s.Seats) - 1
evs := []Event{{Kind: "sit", Seat: You, Amount: buyIn, Text: t.Name}}
return s, evs, nil
}
// ApplyMove is the whole engine. It plays your move, then every bot behind you,
// deals whatever streets that finishes, and stops either when it is your turn
// again or when the hand is over.
func ApplyMove(s State, m Move) (State, []Event, error) {
// soloSeats builds the seat list for a table of one human and n bots — the shape
// every table had before multiplayer, and the one the solo handler still opens.
// The human is seat zero and takes buyIn; the bots take the table maximum.
// SoloSeats is exported for the handler that still opens a solo table. See New.
func SoloSeats(t Tier, bots int, buyIn int64) []SeatConfig {
seats := []SeatConfig{{Name: "You", Stack: buyIn}}
for i := 0; i < bots; i++ {
seats = append(seats, SeatConfig{Name: botNames[i], Bot: true, Stack: t.MaxBuy})
}
return seats
}
// ApplyMove is the whole engine. It plays the acting seat's move, then every bot
// behind them, deals whatever streets that finishes, and stops when the action
// reaches a human — the same one again, or another at the table — or when the
// hand is over.
//
// seat is who is acting. A betting move is legal only from the seat whose turn it
// is; the session moves (Deal, TopUp, Leave) belong to the seat that sent them.
// This is the one place seat identity enters the engine — everything below works
// on seat indices already.
func ApplyMove(s State, seat int, m Move) (State, []Event, error) {
if seat < 0 || seat >= len(s.Seats) {
return s, nil, ErrUnknownMove
}
if s.Phase == PhaseDone {
return s, nil, ErrOver
}
@@ -322,32 +359,36 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
if s.Phase != PhaseHandOver {
return s, nil, ErrHandLive
}
if m.Amount <= 0 || s.Seats[You].Stack+m.Amount > s.Tier.MaxBuy {
if m.Amount <= 0 || s.Seats[seat].Stack+m.Amount > s.Tier.MaxBuy {
return s, nil, ErrBadBuyIn
}
s.Seats[You].Stack += m.Amount
s.Seats[seat].Stack += m.Amount
s.BoughtIn += m.Amount
evs = append(evs, Event{Kind: "topup", Seat: You, Amount: m.Amount})
evs = append(evs, Event{Kind: "topup", Seat: seat, Amount: m.Amount})
case Leave:
if s.Phase != PhaseHandOver {
return s, nil, ErrHandLive
}
// Getting up at a solo table ends the session and pays the stack out; the
// runtime reads Payout and crosses the border. At a shared table leaving is a
// storage operation (LeaveTable swaps the seat for a bot in the settle tx),
// and this branch is not the path taken — see the handler.
s.Phase = PhaseDone
s.Payout = s.Seats[You].Stack
evs = append(evs, Event{Kind: "leave", Seat: You, Amount: s.Payout})
s.Payout = s.Seats[seat].Stack
evs = append(evs, Event{Kind: "leave", Seat: seat, Amount: s.Payout})
case Fold, Check, Call, Raise, Shove:
if s.Phase != PhaseBetting {
return s, nil, ErrNoHand
}
if s.ToAct != You {
if s.ToAct != seat {
return s, nil, ErrNotYourTurn
}
if err := s.act(You, m, &evs); err != nil {
if err := s.act(seat, m, &evs); err != nil {
return s, nil, err // nothing happened; the caller keeps the old state
}
s.ToAct = s.nextCanAct(You)
s.ToAct = s.nextCanAct(seat)
s.advance(&evs, rng, true)
default:
@@ -485,8 +526,8 @@ func (s *State) advance(evs *[]Event, rng *rand.Rand, bots bool) {
continue
}
if s.ToAct == You || !bots {
return // a decision that isn't ours to make
if !s.Seats[s.ToAct].Bot || !bots {
return // a decision a human has to make, or the trainer wants to
}
s.botActs(s.ToAct, evs, rng)
@@ -546,10 +587,19 @@ func (s *State) deal(evs *[]Event, rng *rand.Rand) {
p.Hole[round] = c
}
}
// Only your cards go into the script. The bots' are in the state, on this side
// of the wire, and the only thing that ever turns them over is a showdown.
*evs = append(*evs, Event{Kind: "hole", Seat: You,
Cards: []cards.Card{s.Seats[You].Hole[0], s.Seats[You].Hole[1]}})
// A hole event per dealt seat, each carrying that seat's cards. The engine no
// longer decides who may see what — it cannot, now that a table has more than
// one human — so it emits every hand and the view layer redacts per viewer,
// nulling the cards of every seat but the one watching. That per-seat redaction
// is the whole security boundary once these events fan out over SSE, and it has
// a test that renders each seat's view and greps for anybody else's cards.
for i := range s.Seats {
if s.Seats[i].State == Out {
continue
}
*evs = append(*evs, Event{Kind: "hole", Seat: i,
Cards: []cards.Card{s.Seats[i].Hole[0], s.Seats[i].Hole[1]}})
}
s.ToAct = s.firstPreFlop(bb)
}
@@ -627,18 +677,34 @@ func (s *State) endHand(evs *[]Event) {
s.Pot = 0
s.Side = nil
s.Phase = PhaseHandOver
*evs = append(*evs, Event{Kind: "end", Seat: -1, Amount: s.Seats[You].Stack})
*evs = append(*evs, Event{Kind: "end", Seat: -1})
// Busting is the end of the session, not the end of a hand. There is nothing
// to deal you and nothing to give back, so the table closes and you sit down
// again — which is a buy-in, and a buy-in is a decision worth making on purpose.
if s.Seats[You].Stack <= 0 {
// Busting is the end of a session, not of a hand. At a solo table there is
// nothing to deal the one human and nothing to give back, so the table closes
// and they sit down again — a buy-in, and a buy-in is a decision worth making
// on purpose. At a shared table one human busting is not everyone's business:
// their seat simply goes Out and the runtime offers the rebuy while the table
// plays on. So the whole-session bust only fires when there is a single human.
humans := s.humanSeats()
if len(humans) == 1 && s.Seats[humans[0]].Stack <= 0 {
s.Phase = PhaseDone
s.Payout = 0
*evs = append(*evs, Event{Kind: "bust", Seat: You})
*evs = append(*evs, Event{Kind: "bust", Seat: humans[0]})
}
}
// humanSeats lists the seats a person is sitting in. Bots are the house; the only
// real money at the table is in these.
func (s State) humanSeats() []int {
var out []int
for i := range s.Seats {
if !s.Seats[i].Bot {
out = append(out, i)
}
}
return out
}
// ---- the small stuff -------------------------------------------------------
// next derives this step's generator and advances the step count.

View File

@@ -21,7 +21,7 @@ func TestChipsAreConserved(t *testing.T) {
bots := 1 + game%MaxBots
tier := Tiers[game%len(Tiers)]
s, _, err := New(tier, bots, tier.MaxBuy, tier.RakePct, uint64(game), 7)
s, _, err := New(tier, SoloSeats(tier, bots, tier.MaxBuy), tier.RakePct, uint64(game), 7)
if err != nil {
t.Fatalf("new table: %v", err)
}
@@ -30,7 +30,7 @@ func TestChipsAreConserved(t *testing.T) {
for hand := 0; hand < 8 && s.Phase != PhaseDone; hand++ {
var evs []Event
s, evs, err = ApplyMove(s, Move{Kind: Deal})
s, evs, err = apply(s, Move{Kind: Deal})
if err != nil {
t.Fatalf("game %d hand %d: deal: %v", game, hand, err)
}
@@ -41,7 +41,7 @@ func TestChipsAreConserved(t *testing.T) {
if step > 200 {
t.Fatalf("game %d hand %d: the hand will not end", game, hand)
}
s, _, err = ApplyMove(s, randomMove(s, rng))
s, _, err = apply(s, randomMove(s, rng))
if err != nil {
t.Fatalf("game %d hand %d: %v", game, hand, err)
}
@@ -98,6 +98,21 @@ func stacks(s State) []int64 {
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.
@@ -122,7 +137,7 @@ func randomMove(s State, rng *rand.Rand) Move {
func TestHeadsUpButtonIsTheSmallBlindAndActsFirst(t *testing.T) {
s := table(t, Tiers[0], 1, 200)
s, evs, err := ApplyMove(s, Move{Kind: Deal})
s, evs, err := apply(s, Move{Kind: Deal})
if err != nil {
t.Fatal(err)
}
@@ -154,13 +169,13 @@ 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})
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, _, _ = ApplyMove(s, Move{Kind: Deal})
s, _, _ = apply(s, Move{Kind: Deal})
}
if s.Position(You) != "BB" {
t.Skip("never dealt the big blind")
@@ -170,7 +185,7 @@ func TestTheBigBlindGetsTheirOption(t *testing.T) {
}
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 {
if _, _, err := apply(s, Move{Kind: Check}); err != nil {
t.Errorf("the big blind cannot check their option: %v", err)
}
}
@@ -388,7 +403,7 @@ func TestTheRakeIsFivePercentUnderTheCap(t *testing.T) {
// 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)
s, _, err := New(tier, SoloSeats(tier, 1, tier.MaxBuy), 0.05, 1, 2)
if err != nil {
t.Fatal(err)
}
@@ -462,25 +477,25 @@ func TestASplitPotSplits(t *testing.T) {
func TestYouCannotWalkOutOfALiveHand(t *testing.T) {
s := table(t, Tiers[0], 2, 200)
s, _, _ = ApplyMove(s, Move{Kind: Deal})
s, _, _ = apply(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 {
if _, _, err := apply(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 {
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, _, _ = ApplyMove(s, Move{Kind: Deal})
s, _, _ = apply(s, Move{Kind: Deal})
s = playOut(t, s)
stack := s.Seats[You].Stack
s, _, err := ApplyMove(s, Move{Kind: Leave})
s, _, err := apply(s, Move{Kind: Leave})
if err != nil {
t.Fatal(err)
}
@@ -490,7 +505,7 @@ func TestLeavingTakesTheStackHome(t *testing.T) {
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 {
if _, _, err := apply(s, Move{Kind: Deal}); err != ErrOver {
t.Errorf("dealt a hand at a table we got up from: %v", err)
}
}
@@ -514,12 +529,12 @@ func TestBustingEndsTheSession(t *testing.T) {
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 {
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 := ApplyMove(s, Move{Kind: TopUp, Amount: 50})
s, _, err := apply(s, Move{Kind: TopUp, Amount: 50})
if err != nil {
t.Fatal(err)
}
@@ -534,47 +549,45 @@ func TestATopUpCannotGoOverTheTableMax(t *testing.T) {
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 {
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 player is allowed to know ------------------------------------
// ---- what the raw script carries ------------------------------------------
func TestTheScriptNeverCarriesABotsCards(t *testing.T) {
rng := rand.New(rand.NewPCG(4, 4))
s := table(t, Tiers[0], 3, 200)
// 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)
}
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)
holes := map[int][]cards.Card{}
for _, e := range evs {
if e.Kind == "hole" {
holes[e.Seat] = e.Cards
}
}
}
// 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" {
for i := range s.Seats {
if s.Seats[i].State == Out {
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)
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)
}
}
}
@@ -655,17 +668,17 @@ func TestTheBotsAreActuallyTrained(t *testing.T) {
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)
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 = ApplyMove(s, Move{Kind: Deal})
s, _, err = apply(s, Move{Kind: Deal})
if err != nil {
t.Fatal(err)
}
for s.Phase == PhaseBetting {
s, _, err = ApplyMove(s, randomMove(s, rng))
s, _, err = apply(s, randomMove(s, rng))
if err != nil {
t.Fatal(err)
}
@@ -691,7 +704,7 @@ func TestTheBotsAreActuallyTrained(t *testing.T) {
func table(t *testing.T, tier Tier, bots int, buyIn int64) State {
t.Helper()
s, _, err := New(tier, bots, buyIn, tier.RakePct, 1, 2)
s, _, err := New(tier, SoloSeats(tier, bots, buyIn), tier.RakePct, 1, 2)
if err != nil {
t.Fatalf("new table: %v", err)
}
@@ -710,7 +723,7 @@ func playOut(t *testing.T, s State) State {
move = Move{Kind: Check}
}
var err error
s, _, err = ApplyMove(s, move)
s, _, err = apply(s, move)
if err != nil {
t.Fatalf("playing out: %v", err)
}
@@ -734,7 +747,7 @@ func has(evs []Event, kind string) bool {
// 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, _, _ = ApplyMove(s, Move{Kind: Deal})
s, _, _ = apply(s, Move{Kind: Deal})
before := make([]string, len(s.Seats))
for i := range s.Seats {

View File

@@ -0,0 +1,112 @@
package holdem
import (
"math/rand/v2"
"testing"
)
// The reshape's own guard: a table with more than one human actually plays, and
// the chips still conserve when the person to act is not always seat zero.
//
// The solo suite proves the engine still behaves as it did; this proves the thing
// that changed. Two humans and two bots sit down, and the driver plays whichever
// human the action stops on — which is the whole point of the multiway advance:
// it runs the bots itself and hands control back at every *human* seat, not just
// at seat zero.
// randomMoveFor picks a legal move for a specific seat, the multiway sibling of
// randomMove. It never folds when it can check, so hands actually develop.
func randomMoveFor(s State, seat int, rng *rand.Rand) Move {
owed := s.Owed(seat)
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[seat].Stack > owed && s.canBet() {
if to := s.MinRaiseTo(seat); to < s.MaxRaiseTo(seat) {
legal = append(legal, Move{Kind: Raise, To: to})
}
}
return legal[rng.IntN(len(legal))]
}
func TestMultiwayChipsAreConserved(t *testing.T) {
for game := 0; game < 100; game++ {
rng := rand.New(rand.NewPCG(uint64(game), 71))
tier := Tiers[game%len(Tiers)]
// Two humans, two bots. The humans sit at 0 and 2 so the action genuinely
// lands on a non-zero human seat, which is the case the old engine could not
// have reached.
seats := []SeatConfig{
{Name: "Ana", Stack: tier.MaxBuy},
{Name: "Bot A", Bot: true, Stack: tier.MaxBuy},
{Name: "Bo", Stack: tier.MaxBuy},
{Name: "Bot B", Bot: true, Stack: tier.MaxBuy},
}
s, _, err := New(tier, seats, tier.RakePct, uint64(game), 7)
if err != nil {
t.Fatalf("new table: %v", err)
}
want := chipsAt(s)
for hand := 0; hand < 8 && s.Phase == PhaseHandOver; hand++ {
var evs []Event
s, evs, err = ApplyMove(s, 0, Move{Kind: Deal})
if err != nil {
t.Fatalf("game %d hand %d: deal: %v", game, hand, err)
}
want += reloaded(evs)
check(t, s, want, game, hand, "deal")
for step := 0; s.Phase == PhaseBetting; step++ {
if step > 400 {
t.Fatalf("game %d hand %d: the hand will not end", game, hand)
}
seat := s.ToAct
if s.Seats[seat].Bot {
t.Fatalf("game %d: advance stopped on bot seat %d — it should run bots itself", game, seat)
}
s, _, err = ApplyMove(s, seat, randomMoveFor(s, seat, rng))
if err != nil {
t.Fatalf("game %d hand %d seat %d: %v", game, hand, seat, err)
}
check(t, s, want, game, hand, "move")
}
}
}
}
// TestMultiwayRejectsOutOfTurnMoves pins that a human cannot act when it is
// another human's turn — the betting move is legal only from the seat to act.
func TestMultiwayRejectsOutOfTurnMoves(t *testing.T) {
tier := Tiers[0]
seats := []SeatConfig{
{Name: "Ana", Stack: tier.MaxBuy},
{Name: "Bo", Stack: tier.MaxBuy},
}
s, _, err := New(tier, seats, tier.RakePct, 3, 9)
if err != nil {
t.Fatal(err)
}
s, _, err = ApplyMove(s, 0, Move{Kind: Deal})
if err != nil {
t.Fatal(err)
}
if s.Phase != PhaseBetting {
t.Fatalf("want a live hand, got phase %s", s.Phase)
}
// Whoever is not to act tries to move. It must be refused, and nothing must
// change.
other := 1 - s.ToAct
before := chipsAt(s)
if _, _, err := ApplyMove(s, other, Move{Kind: Call}); err != ErrNotYourTurn {
t.Fatalf("want ErrNotYourTurn from the seat not to act, got %v", err)
}
if got := chipsAt(s); got != before {
t.Errorf("a refused move moved chips: %d -> %d", before, got)
}
}