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

@@ -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 {