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
113 lines
3.5 KiB
Go
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)
|
|
}
|
|
}
|