diff --git a/internal/clock/engine.go b/internal/clock/engine.go index fa04f4c..8415867 100644 --- a/internal/clock/engine.go +++ b/internal/clock/engine.go @@ -1 +1,696 @@ +// 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"}, + } +} diff --git a/internal/clock/registry.go b/internal/clock/registry.go new file mode 100644 index 0000000..062b3e7 --- /dev/null +++ b/internal/clock/registry.go @@ -0,0 +1,116 @@ +package clock + +import ( + "context" + "fmt" + "sync" + + "github.com/felt-app/felt/internal/server/ws" +) + +// Registry manages multiple clock engines, one per tournament. +// Thread-safe for concurrent access. +type Registry struct { + mu sync.RWMutex + engines map[string]*ClockEngine + cancels map[string]context.CancelFunc + hub *ws.Hub +} + +// NewRegistry creates a new clock registry. +func NewRegistry(hub *ws.Hub) *Registry { + return &Registry{ + engines: make(map[string]*ClockEngine), + cancels: make(map[string]context.CancelFunc), + hub: hub, + } +} + +// GetOrCreate returns the clock engine for the given tournament, +// creating one if it doesn't exist. +func (r *Registry) GetOrCreate(tournamentID string) *ClockEngine { + r.mu.Lock() + defer r.mu.Unlock() + + if engine, ok := r.engines[tournamentID]; ok { + return engine + } + + engine := NewClockEngine(tournamentID, r.hub) + r.engines[tournamentID] = engine + return engine +} + +// Get returns the clock engine for the given tournament, or nil if not found. +func (r *Registry) Get(tournamentID string) *ClockEngine { + r.mu.RLock() + defer r.mu.RUnlock() + return r.engines[tournamentID] +} + +// StartTicker starts the ticker goroutine for the given tournament's engine. +// If a ticker is already running, it is stopped first. +func (r *Registry) StartTicker(ctx context.Context, tournamentID string) error { + r.mu.Lock() + defer r.mu.Unlock() + + engine, ok := r.engines[tournamentID] + if !ok { + return fmt.Errorf("clock: no engine for tournament %s", tournamentID) + } + + // Stop existing ticker if any + if cancel, ok := r.cancels[tournamentID]; ok { + cancel() + } + + tickerCtx, cancel := context.WithCancel(ctx) + r.cancels[tournamentID] = cancel + + go StartTicker(tickerCtx, engine) + return nil +} + +// StopTicker stops the ticker for the given tournament. +func (r *Registry) StopTicker(tournamentID string) { + r.mu.Lock() + defer r.mu.Unlock() + + if cancel, ok := r.cancels[tournamentID]; ok { + cancel() + delete(r.cancels, tournamentID) + } +} + +// Remove removes a clock engine and stops its ticker. +func (r *Registry) Remove(tournamentID string) { + r.mu.Lock() + defer r.mu.Unlock() + + if cancel, ok := r.cancels[tournamentID]; ok { + cancel() + delete(r.cancels, tournamentID) + } + delete(r.engines, tournamentID) +} + +// Count returns the number of active clock engines. +func (r *Registry) Count() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.engines) +} + +// Shutdown stops all tickers and clears all engines. +func (r *Registry) Shutdown() { + r.mu.Lock() + defer r.mu.Unlock() + + for id, cancel := range r.cancels { + cancel() + delete(r.cancels, id) + } + for id := range r.engines { + delete(r.engines, id) + } +} diff --git a/internal/clock/ticker.go b/internal/clock/ticker.go index fa04f4c..913ded0 100644 --- a/internal/clock/ticker.go +++ b/internal/clock/ticker.go @@ -1 +1,67 @@ package clock + +import ( + "context" + "encoding/json" + "time" +) + +// StartTicker runs the clock ticker for the given engine. +// It ticks every 100ms and broadcasts clock snapshots: +// - 1/sec during normal play +// - 10/sec (every 100ms) during the final 10 seconds +// +// The ticker stops when the context is cancelled. +func StartTicker(ctx context.Context, engine *ClockEngine) { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + var lastBroadcastSecond int64 = -1 + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + shouldBroadcast, snapshot := engine.Tick() + if !shouldBroadcast { + continue + } + + // Determine broadcast rate: + // - Final 10 seconds: broadcast every tick (10/sec) + // - Normal: broadcast once per second (1/sec) + currentSecond := snapshot.RemainingMs / 1000 + isFinalSeconds := snapshot.RemainingMs <= 10000 + + if isFinalSeconds { + // Broadcast every 100ms tick in final 10 seconds + broadcastSnapshot(engine, snapshot) + lastBroadcastSecond = currentSecond + } else if currentSecond != lastBroadcastSecond { + // Broadcast once per second change + broadcastSnapshot(engine, snapshot) + lastBroadcastSecond = currentSecond + } + } + } +} + +// broadcastSnapshot sends a clock snapshot to all WebSocket clients. +func broadcastSnapshot(engine *ClockEngine, snapshot ClockSnapshot) { + engine.mu.RLock() + hub := engine.hub + tournamentID := engine.tournamentID + engine.mu.RUnlock() + + if hub == nil { + return + } + + data, err := json.Marshal(snapshot) + if err != nil { + return + } + + hub.Broadcast(tournamentID, "clock.tick", data) +}