felt/internal/clock/registry.go
Mikkel Georgsen 9ce05f6c67 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>
2026-03-01 03:51:07 +01:00

116 lines
2.6 KiB
Go

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