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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user