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:
Mikkel Georgsen 2026-03-01 03:51:07 +01:00
parent 8be69688e9
commit 9ce05f6c67
3 changed files with 877 additions and 0 deletions

View file

@ -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
View 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)
}
}

View file

@ -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)
}