games: the clock that plays for whoever walked away, and the guard that stops it playing twice
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
This commit is contained in:
72
internal/web/games_runtime.go
Normal file
72
internal/web/games_runtime.go
Normal file
@@ -0,0 +1,72 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user