Phase C's handler cutover: hold'em now runs on the shared-table runtime instead of the solo game_live_hands blob. Solo is just a table nobody else has joined. - holdem implements tableGame (name/timeout/stacks). timeout auto-checks-or-folds a walked-away seat and marks it away; the audit is per-hand, with each pot's rake on the winner's row alone so HouseTake cannot 4x itself. - New endpoints: sit opens a table (or joins an open bot seat), leave gets you up (LeaveTable + CloseTable behind the last human), plus tables (lobby), stream (SSE), chat and say. The move path loads the player's table, applies at their seat, commits under the version guard, and fans an SSE nudge. - Engine grows Vacate/Occupy (a human leaving/joining between hands) and TableSeats (a named human + bots). The view carries your_seat, since a shared table has no seat-zero-is-you convention. - Storage grows OpenSoloTable (stake+claim+create+seat in one tx), PlayerSeat, and the abandoned-table reaper (AbandonedTables/ReapTable) — the seated-player counterpart to the session reaper, since a walked-away stack is inside a blob the session reaper cannot see. upsertSeat preserves last_seen so an auto-fold never refreshes an away player's clock. Not deployed, and the felt is not rewired yet: the frontend still assumes seat zero is you, so this is browser-unverified. Solo sit/deal/play/leave and two-human join/leave/reaper are covered by tests; the whole suite is green. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
90 lines
4.3 KiB
Go
90 lines
4.3 KiB
Go
package web
|
|
|
|
import (
|
|
"errors"
|
|
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// errNotDue is a timeout that turned out to have nothing to do. The clock scanned
|
|
// a table as expired, took the lock, and found — on decoding the state — that the
|
|
// seat to act is not in fact a waiting human: a real move landed in the instant
|
|
// between the scan and the lock and had not yet bumped the version the clock
|
|
// checked. It is not an error, it is the race resolving the right way, so the
|
|
// clock swallows it silently rather than logging.
|
|
var errNotDue = errors.New("games: nothing to time out")
|
|
|
|
// The table runtime: the game-agnostic half of a shared table.
|
|
//
|
|
// A shared table has two writers where a solo game had one — an HTTP move, and a
|
|
// turn clock acting for whoever walked away — and the whole job of this layer is
|
|
// to let those two coexist without either trusting the other. The rule that makes
|
|
// that work is the database's version column: every write is conditional on the
|
|
// version its writer read, so the two can race freely and exactly one wins.
|
|
//
|
|
// Everything specific to a game — how a move advances it, whose turn it is now,
|
|
// what a settled hand pays — lives behind the tableGame interface. The clock, the
|
|
// lock discipline and the SSE publish do not know whether they are driving poker
|
|
// or UNO, and that is what lets Phase B ship before any engine is multiway.
|
|
|
|
// turnSeconds is how long a human has to act before the clock acts for them. Long
|
|
// enough to read the table and think, short enough that a walked-away player does
|
|
// not hold three others hostage.
|
|
const turnSeconds = 30
|
|
|
|
// bootGrace is how far the turn clock shoves every live deadline out on boot. A
|
|
// deploy takes the in-memory clock with it, so without this the first tick after
|
|
// a restart would find every deadline in the casino already past and auto-act the
|
|
// whole room at once.
|
|
const bootGrace = 30
|
|
|
|
// step is what a game hands back after a move or a timeout: the new state, ready
|
|
// to persist, plus everything the runtime needs to settle and to schedule.
|
|
//
|
|
// The chips are inside State — a hand ending moves the pot within the blob and
|
|
// credits nobody — so there is no payout field. What comes out is the state, the
|
|
// audit of any hand that just ended, and the clock's next deadline.
|
|
type step struct {
|
|
// State is the engine's whole state, marshalled, ready for the table blob.
|
|
State []byte
|
|
// Phase is lifted out of the state so the lobby can read it without decoding.
|
|
Phase string
|
|
// HandNo is the hand this state is on. It advances when a new hand is dealt,
|
|
// and it is the audit key now that a seed no longer reproduces a shared hand.
|
|
HandNo int64
|
|
// Deadline is when the clock must next act, or 0 for none. It is nonzero only
|
|
// when the turn has landed on a *present* human: a bot resolves inside the move
|
|
// and an away human is auto-acted on sight, so neither is ever waited for.
|
|
Deadline int64
|
|
// Audit is the per-seat record of a hand that ended in this step. Empty if no
|
|
// hand settled.
|
|
Audit []storage.Hand
|
|
// Events is the script the felt plays back — the same shape every solo game
|
|
// already returns. It is what the SSE frame and the acting player both animate.
|
|
Events any
|
|
}
|
|
|
|
// tableGame is everything the runtime needs from an engine to run it at a shared
|
|
// table. Each multiplayer game implements it; the clock and the handlers hold it
|
|
// as an interface so they stay game-agnostic.
|
|
type tableGame interface {
|
|
// name is the storage key and the lobby label: "holdem", "uno", "blackjack".
|
|
name() string
|
|
|
|
// timeout acts for the human whose clock has expired — check if the rules
|
|
// allow it, fold otherwise — and advances the table as far as the next
|
|
// decision, exactly as a real move would. seats is the current roster, so the
|
|
// engine can mark the timed-out player away.
|
|
//
|
|
// It returns ErrNotDue (via a nil step, see runClockTable) if, on decode, the
|
|
// seat to act is not in fact a waiting human — which happens when a real move
|
|
// landed in the same instant the clock fired and the version had not yet been
|
|
// bumped when the clock scanned.
|
|
timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error)
|
|
|
|
// stacks reports the chips in front of each seat, index-aligned with the
|
|
// table's seat rows, so the abandoned-table reaper can cash out a walked-away
|
|
// player without knowing how their game is played.
|
|
stacks(state []byte) ([]int64, error)
|
|
}
|