// Package clock provides a server-authoritative tournament clock engine. // The clock counts down each level with nanosecond internal precision, // transitions automatically between levels (rounds and breaks), supports // pause/resume/advance/rewind/jump, and broadcasts state via WebSocket. package clock import ( "encoding/json" "fmt" "sync" "time" "github.com/felt-app/felt/internal/server/ws" ) // ClockState represents the state machine states. type ClockState string const ( StateStopped ClockState = "stopped" StateRunning ClockState = "running" StatePaused ClockState = "paused" ) // OvertimeMode determines behavior when the last level expires. type OvertimeMode string const ( OvertimeRepeat OvertimeMode = "repeat" // Repeat last level indefinitely OvertimeStop OvertimeMode = "stop" // Stop the clock ) // Level represents a single level in the blind structure. type Level struct { Position int `json:"position"` LevelType string `json:"level_type"` // "round" or "break" GameType string `json:"game_type"` // e.g. "nlhe", "plo", "horse" SmallBlind int64 `json:"small_blind"` BigBlind int64 `json:"big_blind"` Ante int64 `json:"ante"` BBAnte int64 `json:"bb_ante"` DurationSeconds int `json:"duration_seconds"` ChipUpDenominationVal *int64 `json:"chip_up_denomination,omitempty"` Notes string `json:"notes,omitempty"` } // Warning represents a warning threshold configuration. type Warning struct { Seconds int `json:"seconds"` Type string `json:"type"` // "audio", "visual", "both" SoundID string `json:"sound_id"` // e.g. "warning_60s" Message string `json:"message"` } // ClockSnapshot is the full clock state sent to clients. type ClockSnapshot struct { TournamentID string `json:"tournament_id"` State string `json:"state"` // "stopped", "running", "paused" CurrentLevel int `json:"current_level"` Level Level `json:"level"` NextLevel *Level `json:"next_level"` RemainingMs int64 `json:"remaining_ms"` TotalElapsedMs int64 `json:"total_elapsed_ms"` ServerTimeMs int64 `json:"server_time_ms"` HandForHand bool `json:"hand_for_hand"` LevelCount int `json:"level_count"` Warnings []Warning `json:"warnings"` } // AuditRecorder is an interface for recording clock audit entries. // Decoupled from the audit package to avoid circular imports. type AuditRecorder interface { RecordClockAction(tournamentID, operatorID, action string, previousState, newState interface{}) } // noopAuditRecorder is a no-op implementation for when no audit trail is configured. type noopAuditRecorder struct{} func (n *noopAuditRecorder) RecordClockAction(_, _, _ string, _, _ interface{}) {} // StateChangeCallback is called whenever the clock state changes meaningfully. // Used for DB persistence. type StateChangeCallback func(tournamentID string, snap ClockSnapshot) // ClockEngine manages the countdown clock for a single tournament. type ClockEngine struct { mu sync.RWMutex tournamentID string state ClockState levels []Level currentLevel int remainingNs int64 lastTick time.Time totalElapsed int64 // nanoseconds of total tournament time (excludes paused) handForHand bool overtimeMode OvertimeMode // Warning configuration warnings []Warning emittedWarnings map[int]bool // tracks which warning thresholds fired for current level // External dependencies hub *ws.Hub audit AuditRecorder onStateChange StateChangeCallback // Ticker control tickerCancel func() } // NewClockEngine creates a new clock engine for the given tournament. func NewClockEngine(tournamentID string, hub *ws.Hub) *ClockEngine { return &ClockEngine{ tournamentID: tournamentID, state: StateStopped, overtimeMode: OvertimeRepeat, warnings: DefaultWarnings(), emittedWarnings: make(map[int]bool), hub: hub, audit: &noopAuditRecorder{}, } } // SetAuditRecorder sets the audit recorder for clock actions. func (e *ClockEngine) SetAuditRecorder(audit AuditRecorder) { e.mu.Lock() defer e.mu.Unlock() e.audit = audit } // SetOnStateChange sets the callback invoked on meaningful state changes. func (e *ClockEngine) SetOnStateChange(cb StateChangeCallback) { e.mu.Lock() defer e.mu.Unlock() e.onStateChange = cb } // SetOvertimeMode sets how the clock behaves when the last level expires. func (e *ClockEngine) SetOvertimeMode(mode OvertimeMode) { e.mu.Lock() defer e.mu.Unlock() e.overtimeMode = mode } // LoadLevels loads blind structure levels into the engine. // Must be called before Start. func (e *ClockEngine) LoadLevels(levels []Level) { e.mu.Lock() defer e.mu.Unlock() e.levels = make([]Level, len(levels)) copy(e.levels, levels) } // Start transitions the clock from stopped to running. func (e *ClockEngine) Start(operatorID string) error { e.mu.Lock() defer e.mu.Unlock() if e.state != StateStopped { return fmt.Errorf("clock: cannot start from state %s", e.state) } if len(e.levels) == 0 { return fmt.Errorf("clock: no levels loaded") } prevState := string(e.state) e.state = StateRunning e.currentLevel = 0 e.remainingNs = int64(e.levels[0].DurationSeconds) * int64(time.Second) e.lastTick = time.Now() e.totalElapsed = 0 e.emittedWarnings = make(map[int]bool) e.audit.RecordClockAction(e.tournamentID, operatorID, "clock.start", map[string]string{"state": prevState}, map[string]interface{}{"state": string(e.state), "level": e.currentLevel}, ) e.notifyStateChange() return nil } // Pause transitions from running to paused. func (e *ClockEngine) Pause(operatorID string) error { e.mu.Lock() defer e.mu.Unlock() if e.state != StateRunning { return fmt.Errorf("clock: cannot pause from state %s", e.state) } prevState := string(e.state) // Calculate remaining time accurately before pausing now := time.Now() elapsed := now.Sub(e.lastTick) e.remainingNs -= elapsed.Nanoseconds() e.totalElapsed += elapsed.Nanoseconds() if e.remainingNs < 0 { e.remainingNs = 0 } e.state = StatePaused e.audit.RecordClockAction(e.tournamentID, operatorID, "clock.pause", map[string]string{"state": prevState}, map[string]interface{}{ "state": string(e.state), "remaining_ns": e.remainingNs, }, ) e.broadcastSnapshot() e.notifyStateChange() return nil } // Resume transitions from paused to running. func (e *ClockEngine) Resume(operatorID string) error { e.mu.Lock() defer e.mu.Unlock() if e.state != StatePaused { return fmt.Errorf("clock: cannot resume from state %s", e.state) } prevState := string(e.state) e.state = StateRunning e.lastTick = time.Now() e.audit.RecordClockAction(e.tournamentID, operatorID, "clock.resume", map[string]string{"state": prevState}, map[string]interface{}{"state": string(e.state)}, ) e.broadcastSnapshot() e.notifyStateChange() return nil } // Stop transitions the clock to stopped (tournament end). func (e *ClockEngine) Stop(operatorID string) error { e.mu.Lock() defer e.mu.Unlock() if e.state == StateStopped { return fmt.Errorf("clock: already stopped") } prevState := string(e.state) // If running, account for elapsed time if e.state == StateRunning { now := time.Now() elapsed := now.Sub(e.lastTick) e.totalElapsed += elapsed.Nanoseconds() } e.state = StateStopped e.audit.RecordClockAction(e.tournamentID, operatorID, "clock.stop", map[string]string{"state": prevState}, map[string]interface{}{"state": string(e.state)}, ) e.broadcastSnapshot() e.notifyStateChange() return nil } // AdvanceLevel moves to the next level. func (e *ClockEngine) AdvanceLevel(operatorID string) error { e.mu.Lock() defer e.mu.Unlock() if len(e.levels) == 0 { return fmt.Errorf("clock: no levels loaded") } prevLevel := e.currentLevel if e.currentLevel >= len(e.levels)-1 { return fmt.Errorf("clock: already at last level") } // If running, account for elapsed time up to now if e.state == StateRunning { now := time.Now() elapsed := now.Sub(e.lastTick) e.totalElapsed += elapsed.Nanoseconds() e.lastTick = now } e.doAdvanceLevel() e.audit.RecordClockAction(e.tournamentID, operatorID, "clock.advance", map[string]interface{}{"level": prevLevel}, map[string]interface{}{"level": e.currentLevel}, ) e.broadcastSnapshot() e.notifyStateChange() return nil } // RewindLevel moves to the previous level. func (e *ClockEngine) RewindLevel(operatorID string) error { e.mu.Lock() defer e.mu.Unlock() if len(e.levels) == 0 { return fmt.Errorf("clock: no levels loaded") } if e.currentLevel <= 0 { return fmt.Errorf("clock: already at first level") } prevLevel := e.currentLevel // If running, account for elapsed time if e.state == StateRunning { now := time.Now() elapsed := now.Sub(e.lastTick) e.totalElapsed += elapsed.Nanoseconds() e.lastTick = now } e.currentLevel-- e.remainingNs = int64(e.levels[e.currentLevel].DurationSeconds) * int64(time.Second) e.emittedWarnings = make(map[int]bool) e.audit.RecordClockAction(e.tournamentID, operatorID, "clock.rewind", map[string]interface{}{"level": prevLevel}, map[string]interface{}{"level": e.currentLevel}, ) // Emit level change event e.emitLevelChangeEvent() e.broadcastSnapshot() e.notifyStateChange() return nil } // JumpToLevel jumps to a specific level by index. func (e *ClockEngine) JumpToLevel(levelIndex int, operatorID string) error { e.mu.Lock() defer e.mu.Unlock() if len(e.levels) == 0 { return fmt.Errorf("clock: no levels loaded") } if levelIndex < 0 || levelIndex >= len(e.levels) { return fmt.Errorf("clock: level index %d out of range [0, %d)", levelIndex, len(e.levels)) } prevLevel := e.currentLevel // If running, account for elapsed time if e.state == StateRunning { now := time.Now() elapsed := now.Sub(e.lastTick) e.totalElapsed += elapsed.Nanoseconds() e.lastTick = now } e.currentLevel = levelIndex e.remainingNs = int64(e.levels[e.currentLevel].DurationSeconds) * int64(time.Second) e.emittedWarnings = make(map[int]bool) e.audit.RecordClockAction(e.tournamentID, operatorID, "clock.jump", map[string]interface{}{"level": prevLevel}, map[string]interface{}{"level": e.currentLevel}, ) e.emitLevelChangeEvent() e.broadcastSnapshot() e.notifyStateChange() return nil } // SetHandForHand enables or disables hand-for-hand mode. // When enabled, the clock pauses. When disabled, the clock resumes. func (e *ClockEngine) SetHandForHand(enabled bool, operatorID string) error { e.mu.Lock() defer e.mu.Unlock() if e.handForHand == enabled { return nil // Already in desired state } e.handForHand = enabled if enabled && e.state == StateRunning { // Pause the clock for hand-for-hand now := time.Now() elapsed := now.Sub(e.lastTick) e.remainingNs -= elapsed.Nanoseconds() e.totalElapsed += elapsed.Nanoseconds() if e.remainingNs < 0 { e.remainingNs = 0 } e.state = StatePaused e.audit.RecordClockAction(e.tournamentID, operatorID, "clock.hand_for_hand_on", map[string]interface{}{"hand_for_hand": false}, map[string]interface{}{"hand_for_hand": true, "state": string(e.state)}, ) } else if !enabled && e.state == StatePaused { // Resume the clock e.state = StateRunning e.lastTick = time.Now() e.audit.RecordClockAction(e.tournamentID, operatorID, "clock.hand_for_hand_off", map[string]interface{}{"hand_for_hand": true}, map[string]interface{}{"hand_for_hand": false, "state": string(e.state)}, ) } e.broadcastSnapshot() e.notifyStateChange() return nil } // SetWarnings updates the warning thresholds. func (e *ClockEngine) SetWarnings(warnings []Warning) { e.mu.Lock() defer e.mu.Unlock() e.warnings = make([]Warning, len(warnings)) copy(e.warnings, warnings) } // GetWarnings returns the current warning thresholds. func (e *ClockEngine) GetWarnings() []Warning { e.mu.RLock() defer e.mu.RUnlock() result := make([]Warning, len(e.warnings)) copy(result, e.warnings) return result } // Snapshot returns the current clock state for reconnecting clients. func (e *ClockEngine) Snapshot() ClockSnapshot { e.mu.RLock() defer e.mu.RUnlock() return e.snapshotLocked() } // snapshotLocked builds a snapshot (caller must hold at least RLock). func (e *ClockEngine) snapshotLocked() ClockSnapshot { snap := ClockSnapshot{ TournamentID: e.tournamentID, State: string(e.state), CurrentLevel: e.currentLevel, HandForHand: e.handForHand, LevelCount: len(e.levels), ServerTimeMs: time.Now().UnixMilli(), Warnings: make([]Warning, len(e.warnings)), } copy(snap.Warnings, e.warnings) if len(e.levels) > 0 && e.currentLevel < len(e.levels) { snap.Level = e.levels[e.currentLevel] // NextLevel preview if e.currentLevel+1 < len(e.levels) { next := e.levels[e.currentLevel+1] snap.NextLevel = &next } } // Calculate remaining time. If running, account for time since last tick. remainingNs := e.remainingNs totalElapsed := e.totalElapsed if e.state == StateRunning { elapsed := time.Since(e.lastTick) remainingNs -= elapsed.Nanoseconds() totalElapsed += elapsed.Nanoseconds() if remainingNs < 0 { remainingNs = 0 } } snap.RemainingMs = remainingNs / int64(time.Millisecond) snap.TotalElapsedMs = totalElapsed / int64(time.Millisecond) return snap } // TournamentID returns the tournament ID this engine belongs to. func (e *ClockEngine) TournamentID() string { return e.tournamentID } // State returns the current clock state. func (e *ClockEngine) State() ClockState { e.mu.RLock() defer e.mu.RUnlock() return e.state } // doAdvanceLevel performs the internal level advancement (caller must hold lock). func (e *ClockEngine) doAdvanceLevel() { prevLevel := e.currentLevel wasBreak := len(e.levels) > 0 && e.currentLevel < len(e.levels) && e.levels[e.currentLevel].LevelType == "break" if e.currentLevel >= len(e.levels)-1 { // Last level -- handle overtime switch e.overtimeMode { case OvertimeRepeat: // Reset the timer for the last level e.remainingNs = int64(e.levels[e.currentLevel].DurationSeconds) * int64(time.Second) case OvertimeStop: e.state = StateStopped e.broadcastSnapshot() return } } else { e.currentLevel++ e.remainingNs = int64(e.levels[e.currentLevel].DurationSeconds) * int64(time.Second) } // Reset emitted warnings for the new level e.emittedWarnings = make(map[int]bool) // Emit events isBreak := e.levels[e.currentLevel].LevelType == "break" if isBreak { e.emitEvent("clock.break_start", map[string]interface{}{ "level": e.currentLevel, "sound": "break_start", }) } if wasBreak && !isBreak && prevLevel != e.currentLevel { e.emitEvent("clock.break_end", map[string]interface{}{ "level": e.currentLevel, "sound": "break_end", }) } e.emitLevelChangeEvent() } // Tick is called by the ticker to advance the clock. Returns true if a // broadcast should be sent. func (e *ClockEngine) Tick() (shouldBroadcast bool, snapshot ClockSnapshot) { e.mu.Lock() defer e.mu.Unlock() if e.state != StateRunning { return false, ClockSnapshot{} } now := time.Now() elapsed := now.Sub(e.lastTick) e.lastTick = now e.remainingNs -= elapsed.Nanoseconds() e.totalElapsed += elapsed.Nanoseconds() // Check for level transition if e.remainingNs <= 0 { // Carry over the deficit to prevent drift overflow := -e.remainingNs e.doAdvanceLevel() if e.state == StateStopped { // Overtime stop return true, e.snapshotLocked() } e.remainingNs -= overflow if e.remainingNs < 0 { e.remainingNs = 0 } } // Check warnings e.checkWarningsLocked() return true, e.snapshotLocked() } // checkWarningsLocked checks warning thresholds (caller must hold lock). func (e *ClockEngine) checkWarningsLocked() { remainingSec := e.remainingNs / int64(time.Second) for _, w := range e.warnings { if int64(w.Seconds) >= remainingSec && !e.emittedWarnings[w.Seconds] { // Threshold crossed e.emittedWarnings[w.Seconds] = true e.emitEvent("clock.warning", map[string]interface{}{ "seconds": w.Seconds, "type": w.Type, "sound": w.SoundID, "message": w.Message, }) } } } // IsFinalSeconds returns true if the remaining time is 10 seconds or less. func (e *ClockEngine) IsFinalSeconds() bool { e.mu.RLock() defer e.mu.RUnlock() return e.remainingNs <= 10*int64(time.Second) } // broadcastSnapshot broadcasts the current snapshot via WebSocket (caller must hold lock). func (e *ClockEngine) broadcastSnapshot() { if e.hub == nil { return } snap := e.snapshotLocked() data, err := json.Marshal(snap) if err != nil { return } e.hub.Broadcast(e.tournamentID, "clock.tick", data) } // emitEvent broadcasts a specific event via WebSocket (caller must hold lock). func (e *ClockEngine) emitEvent(eventType string, payload interface{}) { if e.hub == nil { return } data, err := json.Marshal(payload) if err != nil { return } e.hub.Broadcast(e.tournamentID, eventType, data) } // emitLevelChangeEvent broadcasts a level change event (caller must hold lock). func (e *ClockEngine) emitLevelChangeEvent() { if e.hub == nil { return } e.emitEvent("clock.level_change", map[string]interface{}{ "level": e.currentLevel, "sound": "level_change", }) } // notifyStateChange calls the state change callback if set (caller must hold lock). func (e *ClockEngine) notifyStateChange() { if e.onStateChange != nil { snap := e.snapshotLocked() // Call outside the lock to avoid deadlocks go e.onStateChange(e.tournamentID, snap) } } // RestoreState restores clock state from persisted data (crash recovery). // If the clock was running when the server stopped, it is restored as paused // for safety (operator must explicitly resume). func (e *ClockEngine) RestoreState(currentLevel int, remainingNs int64, totalElapsedNs int64, clockState string, handForHand bool) { e.mu.Lock() defer e.mu.Unlock() if len(e.levels) == 0 { return } if currentLevel < 0 || currentLevel >= len(e.levels) { currentLevel = 0 } e.currentLevel = currentLevel e.remainingNs = remainingNs e.totalElapsed = totalElapsedNs e.handForHand = handForHand e.emittedWarnings = make(map[int]bool) // For safety on crash recovery, always restore as paused // (operator must explicitly resume) switch ClockState(clockState) { case StateRunning: e.state = StatePaused // Paused for safety case StatePaused: e.state = StatePaused default: e.state = StateStopped } } // DefaultWarnings returns the default warning thresholds. func DefaultWarnings() []Warning { return []Warning{ {Seconds: 60, Type: "both", SoundID: "warning_60s", Message: "60 seconds remaining"}, {Seconds: 30, Type: "both", SoundID: "warning_30s", Message: "30 seconds remaining"}, {Seconds: 10, Type: "both", SoundID: "warning_10s", Message: "10 seconds remaining"}, } }