diff --git a/holdem-train b/holdem-train new file mode 100755 index 0000000..b472be8 Binary files /dev/null and b/holdem-train differ diff --git a/internal/games/holdem/eval.go b/internal/games/holdem/eval.go index 177f054..d14ac11 100644 --- a/internal/games/holdem/eval.go +++ b/internal/games/holdem/eval.go @@ -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)) } } diff --git a/internal/games/holdem/holdem.go b/internal/games/holdem/holdem.go index 1690476..b069770 100644 --- a/internal/games/holdem/holdem.go +++ b/internal/games/holdem/holdem.go @@ -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. diff --git a/internal/games/holdem/holdem_test.go b/internal/games/holdem/holdem_test.go index d16085e..47c72db 100644 --- a/internal/games/holdem/holdem_test.go +++ b/internal/games/holdem/holdem_test.go @@ -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 { diff --git a/internal/games/holdem/multiway_test.go b/internal/games/holdem/multiway_test.go new file mode 100644 index 0000000..b855fce --- /dev/null +++ b/internal/games/holdem/multiway_test.go @@ -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) + } +} diff --git a/internal/web/games_holdem.go b/internal/web/games_holdem.go index dd0f324..82fbc26 100644 --- a/internal/web/games_holdem.go +++ b/internal/web/games_holdem.go @@ -44,6 +44,10 @@ type holdemSeatView struct { Won int64 `json:"won,omitempty"` } +// soloViewer is the seat the still-solo handler renders for: the one human, at +// zero. The shared-table path passes the seat of whoever is actually watching. +const soloViewer = 0 + var seatStates = map[holdem.SeatState]string{ holdem.Active: "active", holdem.Folded: "folded", @@ -81,7 +85,17 @@ type holdemView struct { Payout int64 `json:"payout,omitempty"` } -func viewHoldem(g holdem.State) holdemView { +// viewHoldem renders the table as one seat may see it. viewer is which seat is +// looking — their cards are the only hole cards it will ever put in the payload +// (until a showdown turns the rest over), and the action panel is filled in only +// when it is that seat's turn. +// +// This is the security boundary. Before SSE a missed check here leaked one bot's +// cards to one player through a bug in one handler; now the same view fans to +// every subscriber's stream, so a seat that renders anyone else's hole card fans +// it to the whole table. TestHoldemViewNeverLeaksAnotherSeatsCards renders every +// seat's view at every street and greps for cards that are not theirs. +func viewHoldem(g holdem.State, viewer int) holdemView { v := holdemView{ Tier: g.Tier, Button: g.Button, @@ -90,9 +104,9 @@ func viewHoldem(g holdem.State) holdemView { Pot: g.Total(), ToAct: g.ToAct, Phase: string(g.Phase), - Stack: g.Seats[holdem.You].Stack, + Stack: g.Seats[viewer].Stack, BoughtIn: g.BoughtIn, - Rake: g.Paid, // the part you actually paid, not the part the table lifted + Rake: g.Paid, // the part players actually paid, not the part the table lifted Payout: g.Payout, } for _, p := range g.Side { @@ -106,22 +120,22 @@ func viewHoldem(g holdem.State) holdemView { v.Board = append(v.Board, viewCard(c)) } - // The wall. A bot's hand crosses the wire in exactly one situation — the hand - // was shown down and they did not fold — because that is the only situation in - // which a player at a real table would be looking at it. + // The wall. Another seat's hand crosses the wire in exactly one situation — the + // hand was shown down and they did not fold — because that is the only situation + // in which a player at a real table would be looking at it. shown := g.Street == holdem.Showdown for i, p := range g.Seats { seat := holdemSeatView{ Name: p.Name, Bot: p.Bot, - You: i == holdem.You, + You: i == viewer, Stack: p.Stack, Bet: p.Bet, State: seatStates[p.State], Pos: g.Position(i), Won: p.Won, } - mine := i == holdem.You + mine := i == viewer dealt := p.State != holdem.Out && p.Hole[0].Rank != 0 if dealt && (mine || (shown && p.State != holdem.Folded)) { seat.Cards = []cardView{viewCard(p.Hole[0]), viewCard(p.Hole[1])} @@ -129,14 +143,14 @@ func viewHoldem(g holdem.State) holdemView { v.Seats = append(v.Seats, seat) } - if g.Phase == holdem.PhaseBetting && g.ToAct == holdem.You { - v.Owed = g.Owed(holdem.You) + if g.Phase == holdem.PhaseBetting && g.ToAct == viewer { + v.Owed = g.Owed(viewer) v.CanCheck = v.Owed == 0 - v.CanRaise = g.CanRaise(holdem.You) - v.MinRaise = g.MinRaiseTo(holdem.You) - v.MaxRaise = g.MaxRaiseTo(holdem.You) + v.CanRaise = g.CanRaise(viewer) + v.MinRaise = g.MinRaiseTo(viewer) + v.MaxRaise = g.MaxRaiseTo(viewer) } - if top := g.Tier.MaxBuy - g.Seats[holdem.You].Stack; top > 0 { + if top := g.Tier.MaxBuy - g.Seats[viewer].Stack; top > 0 { v.MaxTopUp = top } return v @@ -154,16 +168,22 @@ type holdemEventView struct { Text string `json:"text,omitempty"` } -func viewHoldemEvents(evs []holdem.Event) []holdemEventView { +// viewHoldemEvents redacts the engine's script for one viewer. The engine emits +// every seat's hole cards now (it cannot know who a shared stream is for), so +// this is where the cards that are not the viewer's are stripped — turning a +// "deal seat 3 these two cards" beat into "deal seat 3 two face-down cards". +// +// A card may ride an event only if it is a board card (Seat < 0), the viewer's +// own, or a hand being shown down. Everything else is nulled here, and a missed +// case is the leak that fans one seat's hole card to every subscriber. +func viewHoldemEvents(evs []holdem.Event, viewer int) []holdemEventView { out := make([]holdemEventView, 0, len(evs)) for _, e := range evs { v := holdemEventView{Kind: e.Kind, Seat: e.Seat, Amount: e.Amount, Total: e.Total, Text: e.Text} for _, c := range e.Cards { v.Cards = append(v.Cards, viewCard(c)) } - // A card may ride an event only if it is the board, your own hand, or a hand - // being shown down. Anything else is a bot's business. - if len(v.Cards) > 0 && e.Seat >= 0 && e.Seat != holdem.You && e.Kind != "show" { + if len(v.Cards) > 0 && e.Seat >= 0 && e.Seat != viewer && e.Kind != "show" { v.Cards = nil } out = append(out, v) @@ -215,7 +235,7 @@ func (s *Server) handleHoldemSit(w http.ResponseWriter, r *http.Request) { } seed1, seed2 := newSeeds() - g, evs, err := holdem.New(tier, req.Bots, req.BuyIn, blackjack.DefaultRules().RakePct, seed1, seed2) + g, evs, err := holdem.New(tier, holdem.SoloSeats(tier, req.Bots, req.BuyIn), blackjack.DefaultRules().RakePct, seed1, seed2) if err != nil { _ = storage.Award(user, req.BuyIn) // nobody sat down, so nothing was bought slog.Error("games: holdem sit", "user", user, "err", err) @@ -276,7 +296,7 @@ func (s *Server) handleHoldemMove(w http.ResponseWriter, r *http.Request) { topped = move.Amount } - next, evs, err := holdem.ApplyMove(g, move) + next, evs, err := holdem.ApplyMove(g, soloViewer, move) if err != nil { if topped > 0 { _ = storage.Award(user, topped) // the top-up didn't happen @@ -341,9 +361,9 @@ func (s *Server) persistHoldem(w http.ResponseWriter, user string, g holdem.Stat // A closed session is gone from storage, so the table view has none to show — // but the browser still needs the last board to land the verdict on. if done { - hv := viewHoldem(g) + hv := viewHoldem(g, soloViewer) v.Holdem = &hv } - v.HoldemEvents = viewHoldemEvents(evs) + v.HoldemEvents = viewHoldemEvents(evs, soloViewer) writeJSON(w, v) } diff --git a/internal/web/games_holdem_redact_test.go b/internal/web/games_holdem_redact_test.go new file mode 100644 index 0000000..bcc7ded --- /dev/null +++ b/internal/web/games_holdem_redact_test.go @@ -0,0 +1,143 @@ +package web + +import ( + "encoding/json" + "strings" + "testing" + + "pete/internal/games/holdem" +) + +// The security boundary of the whole multiplayer table, tested the way the plan +// insists it must be: render every seat's view at every street, and string-search +// the JSON for any card that belongs to another seat. Zero hits, for every seat, +// at every point before a showdown turns cards over. +// +// This is not belt-and-braces. After SSE the view a seat renders fans to that +// seat's stream, so a single missed redaction does not leak one card to one +// player through one handler bug — it broadcasts a hole card to the whole felt, +// continuously. The engine deliberately emits every seat's cards now (it cannot +// know who a shared stream is for), which makes this function the only thing +// standing between the deck and the network tab. + +// holeLabel is a seat's two cards as they would be rendered — the exact strings a +// leak would put in the JSON. +func holeLabels(g holdem.State, seat int) []string { + h := g.Seats[seat].Hole + return []string{viewCard(h[0]).Label, viewCard(h[1]).Label} +} + +func TestHoldemViewNeverLeaksAnotherSeatsCards(t *testing.T) { + tier := holdem.Tiers[0] + // Two humans (0, 2) and two bots (1, 3), so the test covers a seat seeing both + // another human and a bot, and a human sitting at a non-zero index. + seats := []holdem.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}, + } + g, _, err := holdem.New(tier, seats, tier.RakePct, 5, 6) + if err != nil { + t.Fatal(err) + } + + // Deal, then walk the hand to the river without anyone folding — every seat + // stays in, so every seat has cards that must not leak to the others. Checking + // and calling keeps everyone live; the redaction is checked after every step. + g, dealEvs, err := holdem.ApplyMove(g, 0, holdem.Move{Kind: holdem.Deal}) + if err != nil { + t.Fatal(err) + } + + // The deal script carries every hole, so it is the sharpest test of event + // redaction: for each viewer, no other seat's cards may survive. + assertEventsRedacted(t, g, dealEvs) + assertViewsRedacted(t, g) + + for step := 0; g.Phase == holdem.PhaseBetting && step < 80; step++ { + seat := g.ToAct + if g.Seats[seat].Bot { + t.Fatalf("advance stopped on a bot at seat %d", seat) + } + move := holdem.Move{Kind: holdem.Check} + if g.Owed(seat) > 0 { + move = holdem.Move{Kind: holdem.Call} + } + var evs []holdem.Event + g, evs, err = holdem.ApplyMove(g, seat, move) + if err != nil { + t.Fatalf("seat %d %s: %v", seat, move.Kind, err) + } + assertEventsRedacted(t, g, evs) + assertViewsRedacted(t, g) + } +} + +// assertViewsRedacted renders every seat's table view and fails if any of them +// carries a card belonging to a seat that is still hiding it. +func assertViewsRedacted(t *testing.T, g holdem.State) { + t.Helper() + shown := g.Street == holdem.Showdown + for viewer := range g.Seats { + blob, err := json.Marshal(viewHoldem(g, viewer)) + if err != nil { + t.Fatal(err) + } + payload := string(blob) + for other := range g.Seats { + if other == viewer { + continue // your own cards are yours to see + } + // A seat's cards are legitimately visible only at a showdown, and then only + // if they did not fold. Everywhere else, a hit is a leak. + if shown && g.Seats[other].State != holdem.Folded { + continue + } + if g.Seats[other].State == holdem.Out { + continue + } + for _, label := range holeLabels(g, other) { + if strings.Contains(payload, label) { + t.Fatalf("seat %d's view (%s street) leaks seat %d's card %q", + viewer, g.Street, other, label) + } + } + } + } +} + +// assertEventsRedacted renders the event script for every viewer and fails if a +// beat carries another seat's card outside a showdown "show". +func assertEventsRedacted(t *testing.T, g holdem.State, evs []holdem.Event) { + t.Helper() + for viewer := range g.Seats { + blob, err := json.Marshal(viewHoldemEvents(evs, viewer)) + if err != nil { + t.Fatal(err) + } + payload := string(blob) + // The engine only puts a non-viewer's cards in a "show" beat and in the hole + // beats it emits for everyone. If any card label of another seat survives the + // redaction *and* there is no show event in this batch, it leaked. + hasShow := false + for _, e := range evs { + if e.Kind == "show" { + hasShow = true + } + } + if hasShow { + continue // a showdown legitimately turns cards over + } + for other := range g.Seats { + if other == viewer || g.Seats[other].State == holdem.Out { + continue + } + for _, label := range holeLabels(g, other) { + if strings.Contains(payload, label) { + t.Fatalf("seat %d's event stream leaks seat %d's card %q", viewer, other, label) + } + } + } + } +} diff --git a/internal/web/games_play.go b/internal/web/games_play.go index fdc1284..1cb5dc5 100644 --- a/internal/web/games_play.go +++ b/internal/web/games_play.go @@ -301,7 +301,7 @@ func (s *Server) table(user string) (tableView, error) { if err := json.Unmarshal(live.State, &g); err != nil { return s.dropUnreadable(user, v, err) } - hv := viewHoldem(g) + hv := viewHoldem(g, soloViewer) v.Holdem = &hv default: return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))