Phase B runtime: the turn clock, the session reaper the plan noticed nobody had ever wired, and the game-agnostic seam the engines will plug into. - tableGame interface + a games() registry keyed on storage name, so the clock, the reaper and (soon) the handlers never know whether they drive poker or UNO. - The turn clock is the first goroutine in Pete to mutate game state. It obeys rule 1 (DueTables returns a plain slice — the rows are closed before any lock, or the scan would hold the one connection a locked write needs) and the version guard (act only if the table is still the version the scan saw). Tested against the exact double-move the plan warned of: a real move lands in the same tick the scan fired, bumps the version, and the clock steps aside instead of folding the next player who still had 25 seconds. - PushDeadlines on boot shoves every live clock out by a grace period, so the first tick after a deploy doesn't auto-fold the whole room at once. - ReapIdleSessions finally has a caller. A seated player is invisible to it — their chips are inside a table blob — so it only ever reaps loose idle chips. - publishTable fans a minimal version-carrying nudge through the hub; the frame is seat-blind, so a hole card never rides a broadcast that reaches the table. Clock wired into main.go behind gamesReady(). Still no engine implements tableGame, so the registry is empty and nothing a player can see has changed. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
73 lines
3.6 KiB
Go
73 lines
3.6 KiB
Go
package web
|
|
|
|
import "pete/internal/storage"
|
|
|
|
// 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)
|
|
}
|