# 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 - `Level` struct: - `Position int` - `LevelType 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 int` - `ChipUpDenomination *int64` (nullable) - `Notes string` - State machine methods: - `Start()` — transition from stopped → running, set lastTick, record audit entry - `Pause()` — transition from running → paused, record remaining time, record audit entry - `Resume()` — transition from paused → running, reset lastTick, record audit entry - `Stop()` — 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): ```go 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: 1. Lock engine mutex 2. If state is not running, skip 3. Calculate elapsed since lastTick using monotonic clock 4. Subtract from remainingNs 5. Update totalElapsedNs 6. Check for level transition (remainingNs <= 0) 7. Determine if broadcast is needed (1/sec or 10/sec) 8. Build ClockSnapshot 9. Unlock mutex 10. 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) *ClockEngine` - `Get(tournamentID string) *ClockEngine` - `Remove(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 **1. Warning System** (`internal/clock/warnings.go`): - `WarningThreshold` struct: `Seconds int`, `Type string` (audio/visual/both), `SoundID string`, `Message string` - Default warning thresholds: 60s, 30s, 10s (configurable per tournament) - `checkWarnings()` method on ClockEngine: - Check if remainingNs just crossed a threshold (was above, now below) - If crossed, emit warning event via WebSocket: `{type: "clock.warning", seconds: 60, sound: "warning_60s"}` - Track which warnings have been emitted for current level (reset on level change) - Don't re-emit if already emitted (prevent duplicate warnings on tick boundary) - 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"}` and `break_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 1. Clock counts down each level with second-granularity display and transitions automatically 2. Breaks display with distinct visual treatment (level_type: "break" in snapshot) 3. Pause/resume works with correct remaining time preservation 4. Forward/backward level advance works 5. Jump to any level by number works 6. Total elapsed time displays correctly (excludes paused time) 7. Warning events fire at configured thresholds (60s, 30s, 10s default) 8. WebSocket clients receive clock ticks at 1/sec (10/sec in final 10s) 9. Reconnecting WebSocket clients receive full clock snapshot immediately 10. Multiple tournament clocks run independently 11. 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)