14 plans in 6 waves covering all 68 requirements for the Tournament Engine phase. Includes research (go-libsql, NATS JetStream, Svelte 5 runes, ICM complexity), plan verification (2 iterations), and user feedback (hand-for-hand UX, SEAT-06 reword, re-entry semantics, integration test, DKK defaults, JWT 7-day expiry, clock tap safety). Wave structure: 1: A (scaffold), B (schema) 2: C (auth/audit), D (clock), E (templates), J (frontend scaffold) 3: F (financial), H (seating), M (layout shell) 4: G (player management) 5: I (tournament lifecycle) 6: K (overview/financials), L (players), N (tables/more) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
11 KiB
Plan D: Clock Engine
wave: 2 depends_on: [01-PLAN-A, 01-PLAN-B] files_modified:
- internal/clock/engine.go
- internal/clock/ticker.go
- internal/clock/warnings.go
- internal/server/routes/clock.go
- internal/clock/engine_test.go
- internal/clock/warnings_test.go autonomous: true requirements: [CLOCK-01, CLOCK-02, CLOCK-03, CLOCK-04, CLOCK-05, CLOCK-06, CLOCK-07, CLOCK-08, CLOCK-09]
Goal
A server-authoritative tournament clock counts down each level with millisecond precision, transitions automatically between levels (rounds and breaks), supports pause/resume/advance/rewind/jump, emits configurable warnings, and broadcasts state to all WebSocket clients at 1/sec (10/sec in final 10s). Reconnecting clients receive a full clock snapshot immediately. Multiple clock engines run independently for multi-tournament support.
Context
- Server-authoritative clock — clients NEVER run their own timer. Server sends absolute state (level, remaining_ms, is_paused, server_timestamp), clients calculate display from server time
- Tick rates: 1/sec normal, 10/sec in final 10 seconds of each level (CLOCK-08)
- Clock state machine: stopped → running ↔ paused → stopped (CLOCK-03)
- Level transitions are automatic — when a level's time expires, advance to next level (CLOCK-01)
- Breaks have distinct treatment — levels can be round or break type (CLOCK-02)
- See 01-RESEARCH.md: Clock Engine State Machine code example, Pitfall 5 (Clock Drift)
User Decisions (from CONTEXT.md)
- Overview tab priority: Clock & current level is the biggest element (top priority)
- Multi-tournament switching — each tournament has its own independent clock (MULTI-01)
- Hand-for-hand mode (SEAT-09) — clock pauses, per-hand deduction. The clock engine must support this mode
Tasks
**1. Clock Engine** (`internal/clock/engine.go`): - `ClockEngine` struct: - `mu sync.RWMutex` - `tournamentID string` - `state ClockState` (stopped/running/paused) - `levels []Level` — the blind structure levels for this tournament - `currentLevel int` — index into levels - `remainingNs int64` — nanoseconds remaining in current level - `lastTick time.Time` — monotonic clock reference for drift-free timing - `totalElapsedNs int64` — total tournament time (excludes paused time) - `pausedAt *time.Time` — when pause started (for elapsed tracking) - `handForHand bool` — hand-for-hand mode flag - `hub *ws.Hub` — WebSocket hub for broadcasting - `auditTrail *audit.AuditTrail` — for recording clock actions-
Levelstruct:Position intLevelType string— "round" or "break"GameType string— e.g., "nlhe", "plo", "horse" (for mixed game rotation)SmallBlind int64(cents)BigBlind int64(cents)Ante int64(cents)BBAnte int64(cents, big blind ante)DurationSeconds intChipUpDenomination *int64(nullable)Notes string
-
State machine methods:
Start()— transition from stopped → running, set lastTick, record audit entryPause()— transition from running → paused, record remaining time, record audit entryResume()— transition from paused → running, reset lastTick, record audit entryStop()— transition to stopped (tournament end)AdvanceLevel()— move to next level (CLOCK-04 forward)RewindLevel()— move to previous level (CLOCK-04 backward)JumpToLevel(levelIndex int)— jump to any level by index (CLOCK-05)SetHandForHand(enabled bool)— toggle hand-for-hand flag on the clock snapshot (called by seating engine, SEAT-09). When enabled, clock pauses. When disabled, clock resumes.
-
advanceLevel()(internal, called when timer expires):- If current level is the last level, enter overtime (repeat last level or stop — configurable)
- Otherwise, increment currentLevel, set remainingNs from new level's DurationSeconds
- Emit level_change event via WebSocket broadcast
- If new level is a break, emit break_start event
- If previous level was a break, emit break_end event
-
Snapshot() ClockSnapshot— returns current state for reconnecting clients (CLOCK-09):type ClockSnapshot struct { TournamentID string `json:"tournament_id"` State string `json:"state"` // "stopped", "running", "paused" CurrentLevel int `json:"current_level"` Level Level `json:"level"` // Current level details NextLevel *Level `json:"next_level"` // Preview of next level (nullable if last) RemainingMs int64 `json:"remaining_ms"` TotalElapsedMs int64 `json:"total_elapsed_ms"` ServerTimeMs int64 `json:"server_time_ms"` // For client drift correction HandForHand bool `json:"hand_for_hand"` LevelCount int `json:"level_count"` // Total levels in structure Warnings []Warning `json:"warnings"` // Active warning thresholds } -
LoadLevels(levels []Level)— load blind structure levels into the engine
2. Ticker (internal/clock/ticker.go):
StartTicker(ctx context.Context, engine *ClockEngine):- Run in a goroutine
- Normal mode: tick every 100ms (check if second changed, broadcast at 1/sec)
- Final 10s mode: broadcast every 100ms (10/sec effective)
- On each tick:
- Lock engine mutex
- If state is not running, skip
- Calculate elapsed since lastTick using monotonic clock
- Subtract from remainingNs
- Update totalElapsedNs
- Check for level transition (remainingNs <= 0)
- Determine if broadcast is needed (1/sec or 10/sec)
- Build ClockSnapshot
- Unlock mutex
- Broadcast via hub (outside the lock)
- Use
time.NewTicker(100 * time.Millisecond)for consistent 100ms checks - Stop ticker on context cancellation
3. Clock Registry (in engine.go or separate):
ClockRegistry— manages multiple clock engines (one per tournament)GetOrCreate(tournamentID string, hub *Hub) *ClockEngineGet(tournamentID string) *ClockEngineRemove(tournamentID string)— cleanup after tournament ends- Thread-safe with sync.RWMutex
Verification:
- Create a clock engine, load 3 levels (2 rounds + 1 break), start it
- Clock counts down, advancing through levels automatically
- Pause stops the countdown, resume continues from where it left
- Snapshot returns correct remaining time at any moment
- Multiple clock engines run independently with different levels
- Level change sound event: when level changes, emit
{type: "clock.level_change", sound: "level_change"} - Break start/end sounds: emit
{type: "clock.break_start", sound: "break_start"}andbreak_end
2. Clock API Routes (internal/server/routes/clock.go):
All routes require auth middleware. Mutation routes require admin or floor role.
-
POST /api/v1/tournaments/{id}/clock/start— start the clock- Load blind structure from DB, create/get clock engine, call Start()
- Response: ClockSnapshot
-
POST /api/v1/tournaments/{id}/clock/pause— pause- Response: ClockSnapshot
-
POST /api/v1/tournaments/{id}/clock/resume— resume- Response: ClockSnapshot
-
POST /api/v1/tournaments/{id}/clock/advance— advance to next level- Response: ClockSnapshot
-
POST /api/v1/tournaments/{id}/clock/rewind— go back to previous level- Response: ClockSnapshot
-
POST /api/v1/tournaments/{id}/clock/jump— body:{"level": 5}- Validate level index is within range
- Response: ClockSnapshot
-
GET /api/v1/tournaments/{id}/clock— get current clock state- Response: ClockSnapshot
- This is also what WebSocket clients receive on connect
-
PUT /api/v1/tournaments/{id}/clock/warnings— body:{"warnings": [{"seconds": 60, "type": "both", "sound": "warning"}]}- Update warning thresholds for this tournament
- Response: updated warnings config
All mutation endpoints:
- Record audit entry (action, previous state, new state)
- Persist clock state to tournament record in DB (currentLevel, remainingNs, state)
- Broadcast updated state via WebSocket hub
3. Clock State Persistence:
- On every meaningful state change (pause, resume, level advance, jump), persist to DB:
UPDATE tournaments SET current_level = ?, clock_state = ?, clock_remaining_ns = ?, total_elapsed_ns = ? WHERE id = ?
- On startup, if a tournament was "running" when the server stopped, resume with adjusted remaining time (or pause it — safer for crash recovery)
4. Tests (internal/clock/engine_test.go, internal/clock/warnings_test.go):
- Test clock counts down correctly over simulated time
- Test pause preserves remaining time exactly
- Test resume continues from paused position
- Test level auto-advance when time expires
- Test jump to specific level sets correct remaining time
- Test rewind to previous level
- Test warning threshold detection (crossing boundary)
- Test warning not re-emitted for same level
- Test hand-for-hand mode pauses clock
- Test multiple independent engines don't interfere
- Test crash recovery: clock persisted as "running" resumes correctly on startup
- Test snapshot includes all required fields (CLOCK-09)
- Test total elapsed time excludes paused periods
Verification:
- Clock API endpoints work via curl
- Clock ticks appear on WebSocket connection
- Warning events fire at correct thresholds
- Level transitions happen automatically
- Clock state survives server restart
Verification Criteria
- Clock counts down each level with second-granularity display and transitions automatically
- Breaks display with distinct visual treatment (level_type: "break" in snapshot)
- Pause/resume works with correct remaining time preservation
- Forward/backward level advance works
- Jump to any level by number works
- Total elapsed time displays correctly (excludes paused time)
- Warning events fire at configured thresholds (60s, 30s, 10s default)
- WebSocket clients receive clock ticks at 1/sec (10/sec in final 10s)
- Reconnecting WebSocket clients receive full clock snapshot immediately
- Multiple tournament clocks run independently
- Clock state persists to DB and survives server restart
Must-Haves (Goal-Backward)
- Server-authoritative clock — clients never run their own timer
- Automatic level transitions (rounds and breaks)
- Pause/resume with visual indicator data in snapshot
- Jump to any level by number
- Configurable warning thresholds with audio/visual events
- 1/sec normal ticks, 10/sec final 10 seconds
- Full clock snapshot on WebSocket connect (reconnection)
- Independent clock per tournament (multi-tournament)
- Clock state persisted to DB (crash recovery)