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>
237 lines
11 KiB
Markdown
237 lines
11 KiB
Markdown
# 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
|
|
|
|
<task id="D1" title="Implement clock engine state machine and ticker">
|
|
**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
|
|
</task>
|
|
|
|
<task id="D2" title="Implement clock warnings and API routes">
|
|
**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
|
|
</task>
|
|
|
|
## 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)
|