- ClockEngine with full state machine (stopped/running/paused transitions) - Level management: load, advance, rewind, jump, hand-for-hand mode - Drift-free ticker at 100ms with 1/sec broadcast (10/sec in final 10s) - ClockRegistry for multi-tournament support (thread-safe) - ClockSnapshot for reconnecting clients (CLOCK-09) - Configurable overtime mode (repeat/stop) - Crash recovery via RestoreState (resumes as paused for safety) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
696 lines
19 KiB
Go
696 lines
19 KiB
Go
// 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"},
|
|
}
|
|
}
|