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:
prosolis
2026-07-14 15:49:02 -07:00
parent 4b3e5fe4c5
commit 004fca3f25
5 changed files with 422 additions and 0 deletions

View 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)
}