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:
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user