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

208
internal/web/games_clock.go Normal file
View File

@@ -0,0 +1,208 @@
package web
import (
"context"
"encoding/json"
"errors"
"log/slog"
"time"
"pete/internal/storage"
)
// The turn clock: the first goroutine in Pete that has ever mutated game state.
//
// Every other background loop here reads, refills or prunes. This one plays. When
// a human sits at a felt and walks away mid-hand, three other people are waiting
// on a decision that is never coming, and something has to make it for them. That
// something is this.
//
// It is built on one discipline and one guard.
//
// The discipline is rule 1: **collect the due tables and close the rows before
// taking any lock.** The scan reads *sql.Rows from the one-connection pool; a lock
// taken while those rows are open would hold the connection the rows need, and the
// process would wedge. So DueTables returns a plain slice and the connection is
// free before the first table is touched.
//
// The guard is the version. The scan records each table's version; the clock acts
// on a table only if it is *still* that version by the time it holds the lock and
// has reloaded. Without that check the clock and a real move race and both land:
// Bob raises in the same second his clock expires, the action moves to Cara, and
// the clock — still believing the seat that ran out of time is to act — folds
// Cara, who had twenty-five seconds left. The version check turns that into a
// no-op: Bob's move bumped the version, the reload shows the new one, and the
// clock steps aside.
// clockInterval is how often the clock looks for expired turns. Sub-second
// precision buys nothing at a card table and would only spin the CPU.
const clockInterval = time.Second
// reaperInterval is how often idle sessions are cashed out. The reaper is a
// slow-moving safety net — a player who wandered off half an hour ago is not in a
// hurry — so it runs far less often than the clock.
const reaperInterval = time.Minute
// games returns the multiplayer engines by their storage key. It is the registry
// the clock and the handlers both dispatch through. Empty until an engine is
// wired; a clock over no games is a loop that finds nothing, which is correct.
func (s *Server) games() map[string]tableGame {
out := make(map[string]tableGame)
for _, g := range s.tableGames {
out[g.name()] = g
}
return out
}
// StartTableClock launches the turn clock and the session reaper if the casino is
// on. Safe to call unconditionally.
func (s *Server) StartTableClock(ctx context.Context) {
if !s.gamesReady() {
return
}
// A deploy just took every table's clock down with it. Shove the live deadlines
// out so the first tick does not auto-act the whole room at once.
if err := storage.PushDeadlines(bootGrace); err != nil {
slog.Error("games: push deadlines on boot", "err", err)
}
go s.runTableClock(ctx)
go s.runSessionReaper(ctx)
}
func (s *Server) runTableClock(ctx context.Context) {
slog.Info("games: turn clock started", "interval", clockInterval)
ticker := time.NewTicker(clockInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.tickClock()
}
}
}
// tickClock finds the tables whose turn has expired and acts on each. The scan is
// done and the connection released before any table is locked — rule 1.
func (s *Server) tickClock() {
due, err := storage.DueTables(time.Now().Unix())
if err != nil {
slog.Error("games: due tables", "err", err)
return
}
for _, ref := range due {
s.runClockTable(ref)
}
}
// runClockTable acts for the walked-away player at one table, but only if the
// table is still at the version the scan saw.
//
// The whole read-modify-write is done under the table's stripe, which is what
// keeps the clock from racing a second copy of itself — but the stripe is only an
// optimisation. The version check inside CommitTable is the real thing: even
// across a redeploy, when two processes hold two different stripes over this row,
// the one whose write lands first bumps the version and the other's write finds
// zero rows and rolls back.
func (s *Server) runClockTable(ref storage.TableRef) {
err := s.tableLocks.withTable(ref.ID, func() error {
t, seats, err := storage.LoadTable(ref.ID)
if errors.Is(err, storage.ErrNoSuchTable) {
return nil // closed out from under us; nothing to do
}
if err != nil {
return err
}
// Somebody moved between the scan and now. Their move set a fresh deadline (or
// cleared it), so this expiry is stale — step aside.
if t.Version != ref.Version {
return nil
}
// A deadline in the future means the scan is looking at an old view; leave it.
if t.Deadline == 0 || t.Deadline > time.Now().Unix() {
return nil
}
game := s.games()[t.Game]
if game == nil {
slog.Error("games: clock over unknown game", "game", t.Game, "table", t.ID)
return nil
}
st, newSeats, err := game.timeout(t.State, seats)
if err != nil {
slog.Error("games: clock timeout", "table", t.ID, "err", err)
return nil
}
t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline
if err := storage.CommitTable(storage.TableCommit{Table: t, Seats: newSeats, Audit: st.Audit}); err != nil {
if errors.Is(err, storage.ErrStaleTable) {
return nil // lost the race after all; the winner handled it
}
return err
}
s.publishTable(ref.ID)
return nil
})
if err != nil {
slog.Error("games: clock table", "table", ref.ID, "err", err)
}
}
// publishTable pushes the current table view to everyone watching it. It reads
// the table fresh (the authoritative state) and fans an opaque frame out through
// the hub. Called after every committed write, under no lock the hub cares about
// — the hub's sends are non-blocking, so this never stalls a caller.
//
// A view here is deliberately seat-blind: it carries only what every seat may see
// (the version and the public table shape), and each subscriber's own stream
// redacts and re-renders for the seat that is watching. That keeps a hole card
// from ever entering a frame that fans to the whole table.
func (s *Server) publishTable(tableID string) {
if s.hub.watchers(tableID) == 0 {
return
}
t, _, err := storage.LoadTable(tableID)
if errors.Is(err, storage.ErrNoSuchTable) {
return
}
if err != nil {
slog.Error("games: publish table load", "table", tableID, "err", err)
return
}
// The frame is just a nudge carrying the version: a subscriber that sees a gap
// refetches the authoritative, per-seat table. So the payload can be minimal.
data, _ := json.Marshal(map[string]any{"version": t.Version, "phase": t.Phase})
s.hub.publish(tableID, hubFrame{Version: t.Version, Data: data})
}
// runSessionReaper cashes out players who wandered off, on a timer. This is the
// loop the plan noted never existed: ReapIdleSessions has always been safe to run
// and nothing ever ran it, so chips in abandoned *solo* sessions sat in limbo.
//
// A seated player is invisible to it — their chips are inside a table blob, not on
// their game_chips stack — so it only ever reaps a player who is genuinely idle
// with loose chips. Getting up from a table returns them to the stack, and then
// this is what eventually sends them home.
func (s *Server) runSessionReaper(ctx context.Context) {
slog.Info("games: session reaper started", "interval", reaperInterval, "idle", storage.SessionIdleAfter)
ticker := time.NewTicker(reaperInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
n, err := storage.ReapIdleSessions(storage.SessionIdleAfter)
if err != nil {
slog.Error("games: reap idle sessions", "err", err)
continue
}
if n > 0 {
slog.Info("games: reaped idle sessions", "count", n)
}
}
}
}

View File

@@ -0,0 +1,140 @@
package web
import (
"path/filepath"
"testing"
"time"
"pete/internal/storage"
)
// fakeGame is a tableGame that records whether its clock ever fired. It lets the
// runtime tests exercise the lock discipline and the version guard without a real
// engine — the thing under test is the clock, not the cards.
type fakeGame struct {
fired int
}
func (g *fakeGame) name() string { return "fake" }
func (g *fakeGame) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) {
g.fired++
// Act: clear the deadline and bump the hand, as a real settle would.
return step{State: []byte(`{"acted":true}`), Phase: "handover", HandNo: 2, Deadline: 0}, seats, nil
}
// clockTestServer stands up a Server with just the table machinery wired and a
// fresh DB. Enough to drive the clock, nothing else.
func clockTestServer(t *testing.T, g tableGame) *Server {
t.Helper()
// Reset the storage singleton onto a temp DB.
if err := storage.Init(filepath.Join(t.TempDir(), "clock.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
s := &Server{hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{g}}
return s
}
func openClockTable(t *testing.T, id string, deadline int64) storage.Table {
t.Helper()
tbl := storage.Table{
ID: id, Game: "fake", Tier: "1-2", State: []byte(`{}`),
Seed1: 1, Seed2: 2, Phase: "betting", HandNo: 1, Deadline: deadline,
}
seats := []storage.Seat{{Seat: 0, MatrixUser: "@reala:parodia.dev", Name: "reala"}, {Seat: 1, Name: "bot"}}
if err := storage.OpenTable(tbl, seats); err != nil {
t.Fatal(err)
}
return tbl
}
func TestClock_ActsOnExpiredTable(t *testing.T) {
g := &fakeGame{}
s := clockTestServer(t, g)
openClockTable(t, "t1", time.Now().Unix()-5) // already expired
s.tickClock()
if g.fired != 1 {
t.Fatalf("clock should have fired once, got %d", g.fired)
}
after, _, err := storage.LoadTable("t1")
if err != nil {
t.Fatal(err)
}
if after.Phase != "handover" || after.Deadline != 0 {
t.Errorf("table should have advanced: %+v", after)
}
}
func TestClock_IgnoresFutureDeadlines(t *testing.T) {
g := &fakeGame{}
s := clockTestServer(t, g)
openClockTable(t, "t1", time.Now().Unix()+60)
s.tickClock()
if g.fired != 0 {
t.Fatalf("clock should not have fired on a future deadline, got %d", g.fired)
}
}
// TestClock_VersionGuardStopsTheDoubleMove is the scenario the whole design turns
// on. A move lands in the same tick the clock's scan found the table expired. The
// move bumps the version; the clock, acting on its stale scan, must see the new
// version and step aside rather than acting a second time.
func TestClock_VersionGuardStopsTheDoubleMove(t *testing.T) {
g := &fakeGame{}
s := clockTestServer(t, g)
tbl := openClockTable(t, "t1", time.Now().Unix()-5)
// The scan saw version 0.
due, err := storage.DueTables(time.Now().Unix())
if err != nil {
t.Fatal(err)
}
if len(due) != 1 {
t.Fatalf("want 1 due table, got %d", len(due))
}
// A real move lands first, bumping the version and setting a fresh deadline for
// the next player.
tbl.State = []byte(`{"moved":true}`)
tbl.Deadline = time.Now().Unix() + 30
if err := storage.CommitTable(storage.TableCommit{Table: tbl}); err != nil {
t.Fatal(err)
}
// Now the clock acts on its stale scan (version 0). It must not fire.
s.runClockTable(due[0])
if g.fired != 0 {
t.Fatalf("the version guard should have stopped the clock, but it fired %d time(s)", g.fired)
}
after, _, _ := storage.LoadTable("t1")
if string(after.State) != `{"moved":true}` {
t.Errorf("the real move should stand, got %s", after.State)
}
}
func TestClock_PublishesToWatchers(t *testing.T) {
g := &fakeGame{}
s := clockTestServer(t, g)
openClockTable(t, "t1", time.Now().Unix()-5)
ch, done := s.hub.subscribe("t1")
defer done()
s.tickClock()
select {
case f := <-ch:
if f.Version == 0 {
t.Errorf("frame should carry the bumped version, got %d", f.Version)
}
default:
t.Fatal("a watcher should have received a frame after the clock acted")
}
}

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

View File

@@ -83,6 +83,7 @@ type Server struct {
// until a table is opened. // until a table is opened.
hub *gamesHub hub *gamesHub
tableLocks *stripedLocks tableLocks *stripedLocks
tableGames []tableGame // the multiplayer engines, dispatched through games()
} }
// New builds the server. Templates are parsed once at startup. Each page gets // New builds the server. Templates are parsed once at startup. Each page gets

View File

@@ -300,6 +300,7 @@ func main() {
ws.StartPushSender(ctx) ws.StartPushSender(ctx)
ws.StartAdventureDigest(ctx) ws.StartAdventureDigest(ctx)
ws.StartTriviaBank(ctx) ws.StartTriviaBank(ctx)
ws.StartTableClock(ctx)
} }
} }