feat(01-04): implement clock engine state machine, ticker, and registry
- 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>
This commit is contained in:
parent
8be69688e9
commit
9ce05f6c67
3 changed files with 877 additions and 0 deletions
|
|
@ -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
|
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"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
116
internal/clock/registry.go
Normal file
116
internal/clock/registry.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +1,67 @@
|
||||||
package clock
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue