From 004fca3f25b966d6f8fa76fdfe47c34f2cab5f21 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:49:02 -0700 Subject: [PATCH] games: the clock that plays for whoever walked away, and the guard that stops it playing twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/web/games_clock.go | 208 +++++++++++++++++++++++++++++++ internal/web/games_clock_test.go | 140 +++++++++++++++++++++ internal/web/games_runtime.go | 72 +++++++++++ internal/web/server.go | 1 + main.go | 1 + 5 files changed, 422 insertions(+) create mode 100644 internal/web/games_clock.go create mode 100644 internal/web/games_clock_test.go create mode 100644 internal/web/games_runtime.go diff --git a/internal/web/games_clock.go b/internal/web/games_clock.go new file mode 100644 index 0000000..ae2c36c --- /dev/null +++ b/internal/web/games_clock.go @@ -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) + } + } + } +} diff --git a/internal/web/games_clock_test.go b/internal/web/games_clock_test.go new file mode 100644 index 0000000..5aa714d --- /dev/null +++ b/internal/web/games_clock_test.go @@ -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") + } +} diff --git a/internal/web/games_runtime.go b/internal/web/games_runtime.go new file mode 100644 index 0000000..135c563 --- /dev/null +++ b/internal/web/games_runtime.go @@ -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) +} diff --git a/internal/web/server.go b/internal/web/server.go index ab52b20..9c1e506 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -83,6 +83,7 @@ type Server struct { // until a table is opened. hub *gamesHub tableLocks *stripedLocks + tableGames []tableGame // the multiplayer engines, dispatched through games() } // New builds the server. Templates are parsed once at startup. Each page gets diff --git a/main.go b/main.go index 7c970a4..229aa8b 100644 --- a/main.go +++ b/main.go @@ -300,6 +300,7 @@ func main() { ws.StartPushSender(ctx) ws.StartAdventureDigest(ctx) ws.StartTriviaBank(ctx) + ws.StartTableClock(ctx) } }