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:
208
internal/web/games_clock.go
Normal file
208
internal/web/games_clock.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
140
internal/web/games_clock_test.go
Normal file
140
internal/web/games_clock_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user