Files
Pete/internal/games/holdem/multiway_test.go
prosolis 5b381b03ff 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
2026-07-14 16:05:19 -07:00

113 lines
3.5 KiB
Go

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)
}
}