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
141 lines
4.0 KiB
Go
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")
|
|
}
|
|
}
|