Files
Pete/internal/web/games_clock_test.go
prosolis 004fca3f25 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
2026-07-14 15:49:02 -07:00

141 lines
4.0 KiB
Go

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