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

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