feat(server): initial implementation

This commit captures the full server code accumulated across
several development sessions. It includes the following layers,
applied in roughly this order during development:

1. Base infrastructure
   - cmd/poc-server HTTP+WebSocket server, /health, /stats
   - internal/transport wire types (Envelope + all message types)
   - internal/catalog, internal/realtime, internal/stats
   - internal/storage/postgres with migrations 001-004
   - .env, .env.example, docker-compose, Dockerfile, scripts/

2. Swagger documentation
   - go get github.com/swaggo/http-swagger
   - swag init produces docs/docs.go
   - /swagger/index.html UI, /swagger/doc.json spec
   - annotations on health/stats/ws and catalog handlers

3. Races API
   - internal/races/{store,service,keyset,types}.go
   - migration 005_races.sql: finished_races, race_plans, race_queue
   - GET /api/races with keyset pagination, status filter
   - GET /api/races/upcoming
   - POST /api/races/queue/join, GET, DELETE
   - POST/GET /api/races/plans, DELETE /{id}
   - lobby.RaceMeta simplification (no host_id/host_name)

4. Races seeder
   - internal/races/seed with deterministic generator
   - --seed-races / --reset CLI flags in main.go
   - 30 finished + 5 live + 5 plans + 4 queue entries

5. Drivers and clans
   - internal/drivers, internal/clans (CRUD, validation)
   - migration 008_drivers_clans.sql
   - /api/clans and /api/drivers endpoints
   - 3-letter uppercase nickname/tag validation
   - lobby.DriverMeta: Nickname, AvatarURL, ClanID, ClanTag

6. Podium
   - internal/transport.RacePodiumEntry
   - lobby.SetDriverProfile for in-memory metadata sync
   - migration 007_podium.sql (podium JSONB column)
   - seeder populates top-3 per finished race

7. Live races persistence
   - migration 009_live_persistence.sql: live_races,
     live_race_drivers, lobby_drivers
   - internal/races/live_store.go: LiveStore with write-side
     mirror for lobby.Service mutations
   - Service.collectLive and Upcoming read from Postgres
   - main.go RestoreFromDB rehydrates lobby on startup

8. Unify live + finished into one races table
   - migration 010_unify_races.sql: rename finished_races to
     races, expand status CHECK, merge live rows
   - PgStore now hosts both write paths; LiveStore is a
     thin facade implementing lobby.Persistence
   - seeder resetAll drops only finished/cancelled rows and
     race_plans / race_queue / lobby_drivers

Each layer is consistent on its own; cross-layer changes are
visible in the file history. A future refactor may split this
commit into the per-stage boundaries listed above.
This commit is contained in:
x0gp
2026-06-22 22:01:09 +04:00
commit 978d36c505
71 changed files with 23500 additions and 0 deletions
+501
View File
@@ -0,0 +1,501 @@
package lobby
import (
"context"
"errors"
"fmt"
"strings"
"sync/atomic"
"time"
)
// Errors returned by the lobby service.
var (
ErrRaceNotFound = errors.New("race not found")
ErrRaceFull = errors.New("race is full")
ErrRaceNotOpen = errors.New("race is not open for joining")
ErrRaceAlreadyExists = errors.New("race already exists")
ErrDriverNotFound = errors.New("driver not found")
ErrInvalidInput = errors.New("invalid input")
)
// Options for CreateRace.
type CreateRaceOptions struct {
ID string // optional; auto-generated if empty
Name string // required
TrackID string // optional, defaults to "default"
MaxCars int // required, 1..8
Laps int // 0 = time-based
TimeLimitS int // 0 = lap-based
}
// DefaultMaxCars is the fallback when CreateRaceOptions doesn't specify.
const DefaultMaxCars = 4
// raceCounter assigns sequential IDs when caller doesn't provide one.
var raceCounter seqCounter
// CreateRace registers a new race in the lobby.
//
// Rules:
// - race name is required (non-empty)
// - max_cars must be in [1, 8]
// - laps and time_limit_s can't both be 0
//
// Races are owned by a physical track; there is no host driver. Drivers
// join via AddDriverToRace.
func (s *Service) CreateRace(opts CreateRaceOptions) (RaceMeta, error) {
name := strings.TrimSpace(opts.Name)
if name == "" {
return RaceMeta{}, fmt.Errorf("%w: name required", ErrInvalidInput)
}
max := opts.MaxCars
if max == 0 {
max = DefaultMaxCars
}
if max < 1 || max > 8 {
return RaceMeta{}, fmt.Errorf("%w: max_cars must be 1..8", ErrInvalidInput)
}
if opts.Laps == 0 && opts.TimeLimitS == 0 {
return RaceMeta{}, fmt.Errorf("%w: laps or time_limit_s required", ErrInvalidInput)
}
s.mu.Lock()
id := opts.ID
if id == "" {
id = fmt.Sprintf("race-%d-%d", time.Now().Unix(), raceCounter.v.Add(1))
} else if _, exists := s.races[id]; exists {
s.mu.Unlock()
return RaceMeta{}, ErrRaceAlreadyExists
}
track := opts.TrackID
if track == "" {
track = "default"
}
meta := RaceMeta{
ID: id,
Name: name,
TrackID: track,
MaxCars: max,
Laps: opts.Laps,
TimeLimitS: opts.TimeLimitS,
DriverIDs: []string{},
Status: RaceStatusLobby,
CreatedMs: time.Now().UnixMilli(),
}
s.races[id] = &meta
hook := s.onRaceCreated
s.mu.Unlock()
if hook != nil {
hook(meta)
}
s.bump("race_created", id, "")
s.mirror(func(ctx context.Context, p Persistence) error { return p.UpsertRace(ctx, meta) })
return meta, nil
}
// AddRace inserts a race into the in-memory lobby without triggering a
// persistence write. Used by RestoreFromDB where the row is already
// in Postgres. Bumps the lobby version so subscribers re-sync.
func (s *Service) AddRace(r RaceMeta) {
if r.ID == "" {
return
}
s.mu.Lock()
r2 := r
s.races[r.ID] = &r2
s.mu.Unlock()
s.bump("race_restored", r.ID, "")
}
// AddDriver registers a new driver or refreshes an existing one.
// If the driver already exists with a different name, the name is updated.
func (s *Service) AddDriver(id, name, country string) (DriverMeta, error) {
if id == "" {
return DriverMeta{}, fmt.Errorf("%w: id required", ErrInvalidInput)
}
if name == "" {
name = id
}
s.mu.Lock()
now := time.Now().UnixMilli()
broadcastKind := ""
if existing, ok := s.drivers[id]; ok {
existing.LastSeenMs = now
if name != "" {
existing.Name = name
}
if country != "" {
existing.Country = country
}
if existing.Status == DriverStatusOffline {
existing.Status = DriverStatusIdle
broadcastKind = "driver_joined"
}
out := *existing
s.mu.Unlock()
if broadcastKind != "" {
s.bump(broadcastKind, "", id)
}
return out, nil
}
d := DriverMeta{
ID: id,
Name: name,
Status: DriverStatusIdle,
ConnectedMs: now,
LastSeenMs: now,
Country: country,
AvatarHue: hashHue(id),
}
s.drivers[id] = &d
s.mu.Unlock()
s.bump("driver_joined", "", id)
s.mirror(func(ctx context.Context, p Persistence) error {
return p.UpsertDriver(ctx, d)
})
return d, nil
}
// RemoveDriver marks a driver as offline. The driver is kept in memory
// briefly to allow reconnect grace (see Snapshot filter).
func (s *Service) RemoveDriver(id string) {
var raceIDToRemove string
s.mu.Lock()
d, ok := s.drivers[id]
if !ok {
s.mu.Unlock()
return
}
// If still racing, treat as crash — remove from race.
if d.Status == DriverStatusRacing && d.RaceID != "" {
if race, ok := s.races[d.RaceID]; ok {
race.DriverIDs = removeString(race.DriverIDs, id)
if len(race.DriverIDs) == 0 {
delete(s.races, d.RaceID)
raceIDToRemove = d.RaceID
}
}
}
d.Status = DriverStatusOffline
d.LastSeenMs = time.Now().UnixMilli()
d.RaceID = ""
hook := s.onRaceRemoved
s.mu.Unlock()
if raceIDToRemove != "" && hook != nil {
hook(raceIDToRemove)
}
s.bump("driver_left", "", id)
if raceIDToRemove != "" {
s.mirror(func(ctx context.Context, p Persistence) error {
return p.DeleteRace(context.Background(), raceIDToRemove)
})
}
s.mirror(func(ctx context.Context, p Persistence) error {
return p.DeleteDriver(ctx, id)
})
}
// Heartbeat refreshes LastSeenMs (call periodically from session).
func (s *Service) Heartbeat(id string) {
s.mu.Lock()
if d, ok := s.drivers[id]; ok {
d.LastSeenMs = time.Now().UnixMilli()
}
s.mu.Unlock()
}
// SetDriverProfile updates the denormalised display fields of a driver
// (nickname, avatar URL, clan). Safe to call on a missing driver (no-op).
func (s *Service) SetDriverProfile(id, nickname, avatarURL, clanID, clanTag string) {
s.mu.Lock()
var d *DriverMeta
if v, ok := s.drivers[id]; ok {
v.Nickname = nickname
v.AvatarURL = avatarURL
v.ClanID = clanID
v.ClanTag = clanTag
d = v
}
s.mu.Unlock()
if d != nil {
s.mirror(func(ctx context.Context, p Persistence) error {
return p.UpsertDriver(ctx, *d)
})
}
}
// AddDriverToRace moves a driver from idle into a race. Used by the
// client->server JoinRace flow (in addition to the race engine state).
// Idempotent: re-joining the same race is a no-op.
func (s *Service) AddDriverToRace(driverID, raceID string) error {
s.mu.Lock()
d, ok := s.drivers[driverID]
if !ok {
s.mu.Unlock()
return ErrDriverNotFound
}
r, ok := s.races[raceID]
if !ok {
s.mu.Unlock()
return ErrRaceNotFound
}
if r.Status != RaceStatusLobby {
s.mu.Unlock()
return fmt.Errorf("%w: status=%s", ErrRaceNotOpen, r.Status)
}
if len(r.DriverIDs) >= r.MaxCars {
s.mu.Unlock()
return ErrRaceFull
}
if containsString(r.DriverIDs, driverID) {
d.Status = DriverStatusRacing
d.RaceID = raceID
s.mu.Unlock()
return nil
}
r.DriverIDs = append(r.DriverIDs, driverID)
d.Status = DriverStatusRacing
d.RaceID = raceID
slot := len(r.DriverIDs) - 1
s.mu.Unlock()
s.bump("race_updated", raceID, driverID)
s.mirror(func(ctx context.Context, p Persistence) error {
return p.AddDriver(ctx, raceID, driverID, slot)
})
s.mirror(func(ctx context.Context, p Persistence) error {
return p.UpsertDriver(ctx, *d)
})
return nil
}
// RemoveDriverFromRace returns a driver to the lobby.
func (s *Service) RemoveDriverFromRace(driverID string) {
var raceIDRemoved string
s.mu.Lock()
d, ok := s.drivers[driverID]
if !ok {
s.mu.Unlock()
return
}
if d.RaceID == "" {
d.Status = DriverStatusIdle
s.mu.Unlock()
return
}
r, ok := s.races[d.RaceID]
if ok {
r.DriverIDs = removeString(r.DriverIDs, driverID)
if len(r.DriverIDs) == 0 {
delete(s.races, d.RaceID)
raceIDRemoved = d.RaceID
}
}
d.Status = DriverStatusIdle
d.RaceID = ""
prevRaceID := d.RaceID
hook := s.onRaceRemoved
s.mu.Unlock()
if raceIDRemoved != "" && hook != nil {
hook(raceIDRemoved)
}
s.bump("race_updated", prevRaceID, driverID)
if prevRaceID != "" {
s.mirror(func(ctx context.Context, p Persistence) error {
return p.RemoveDriver(ctx, prevRaceID, driverID)
})
}
s.mirror(func(ctx context.Context, p Persistence) error {
return p.UpsertDriver(ctx, *d)
})
if raceIDRemoved != "" {
s.mirror(func(ctx context.Context, p Persistence) error {
return p.DeleteRace(ctx, raceIDRemoved)
})
}
}
// SetRaceStatus updates the status (used by Engine when race starts/ends).
func (s *Service) SetRaceStatus(raceID string, status RaceStatus) error {
s.mu.Lock()
r, ok := s.races[raceID]
if !ok {
s.mu.Unlock()
return ErrRaceNotFound
}
r.Status = status
if status == RaceStatusRacing && r.StartedMs == 0 {
r.StartedMs = time.Now().UnixMilli()
}
startedMs := r.StartedMs
var affected []DriverMeta
if status == RaceStatusFinished {
for _, did := range r.DriverIDs {
if d, ok := s.drivers[did]; ok {
d.Status = DriverStatusIdle
d.RaceID = ""
affected = append(affected, *d)
}
}
}
s.mu.Unlock()
s.bump("race_updated", raceID, "")
s.mirror(func(ctx context.Context, p Persistence) error {
return p.SetRaceStatus(ctx, raceID, status, startedMs)
})
for i := range affected {
d := affected[i]
s.mirror(func(ctx context.Context, p Persistence) error {
return p.UpsertDriver(ctx, d)
})
}
return nil
}
// DeleteRace removes a race entirely. Drivers inside are returned to idle.
func (s *Service) DeleteRace(raceID string) error {
s.mu.Lock()
r, ok := s.races[raceID]
if !ok {
s.mu.Unlock()
return ErrRaceNotFound
}
for _, did := range r.DriverIDs {
if d, ok := s.drivers[did]; ok {
d.Status = DriverStatusIdle
d.RaceID = ""
}
}
delete(s.races, raceID)
hook := s.onRaceRemoved
s.mu.Unlock()
if hook != nil {
hook(raceID)
}
s.bump("race_removed", raceID, "")
s.mirror(func(ctx context.Context, p Persistence) error {
return p.DeleteRace(ctx, raceID)
})
return nil
}
// QuickPlay assigns a driver to the first available race, or creates
// a new one if none exist. Returns the chosen race.
func (s *Service) QuickPlay(driverID string, defaultMaxCars int) (RaceMeta, error) {
var candidateID string
s.mu.RLock()
for _, r := range s.races {
if r.Status == RaceStatusLobby && len(r.DriverIDs) < r.MaxCars {
candidateID = r.ID
break
}
}
s.mu.RUnlock()
if candidateID != "" {
if err := s.AddDriverToRace(driverID, candidateID); err != nil {
return RaceMeta{}, err
}
return s.GetRace(candidateID)
}
driver, err := s.GetDriver(driverID)
if err != nil {
return RaceMeta{}, err
}
race, err := s.CreateRace(CreateRaceOptions{
Name: driver.Name + "'s race",
MaxCars: defaultMaxCars,
Laps: 5,
})
if err != nil {
return RaceMeta{}, err
}
if err := s.AddDriverToRace(driverID, race.ID); err != nil {
return RaceMeta{}, err
}
return s.GetRace(race.ID)
}
// GetRace returns the current meta for a race (by value, safe to mutate).
func (s *Service) GetRace(raceID string) (RaceMeta, error) {
s.mu.RLock()
defer s.mu.RUnlock()
r, ok := s.races[raceID]
if !ok {
return RaceMeta{}, ErrRaceNotFound
}
return *r, nil
}
// GetDriver returns the current meta for a driver.
func (s *Service) GetDriver(driverID string) (DriverMeta, error) {
s.mu.RLock()
defer s.mu.RUnlock()
d, ok := s.drivers[driverID]
if !ok {
return DriverMeta{}, ErrDriverNotFound
}
return *d, nil
}
// ListRaces returns a snapshot copy of all races.
func (s *Service) ListRaces() []RaceMeta {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]RaceMeta, 0, len(s.races))
for _, r := range s.races {
out = append(out, *r)
}
return out
}
// ListDrivers returns a snapshot copy of all non-offline drivers.
func (s *Service) ListDrivers() []DriverMeta {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]DriverMeta, 0, len(s.drivers))
for _, d := range s.drivers {
if d.Status != DriverStatusOffline {
out = append(out, *d)
}
}
return out
}
// helpers -----------------------------------------------------------------
func removeString(s []string, v string) []string {
for i, x := range s {
if x == v {
return append(s[:i], s[i+1:]...)
}
}
return s
}
func containsString(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// hashHue returns a stable 0..360 hue for the given string.
func hashHue(s string) int {
var h uint32 = 5381
for i := 0; i < len(s); i++ {
h = h*33 ^ uint32(s[i])
}
return int(h % 360)
}
type seqCounter struct{ v atomic.Uint64 }
+120
View File
@@ -0,0 +1,120 @@
package lobby
import (
"strings"
"testing"
"time"
)
func TestAddDriver(t *testing.T) {
s := NewService()
d, err := s.AddDriver("d1", "Alice", "RU")
if err != nil {
t.Fatalf("AddDriver: %v", err)
}
if d.Status != DriverStatusIdle {
t.Errorf("expected idle, got %s", d.Status)
}
if d.Name != "Alice" {
t.Errorf("name: got %s", d.Name)
}
}
func TestCreateRaceValidates(t *testing.T) {
s := NewService()
_, _ = s.AddDriver("d1", "Alice", "")
tests := []struct {
name string
opts CreateRaceOptions
want string // substring of error message or "" for OK
}{
{"empty name", CreateRaceOptions{Name: "", MaxCars: 2, Laps: 1}, "name required"},
{"max cars too low", CreateRaceOptions{Name: "r", MaxCars: 0, Laps: 1}, ""}, // defaults
{"max cars too high", CreateRaceOptions{Name: "r", MaxCars: 9, Laps: 1}, "max_cars must be"},
{"no laps and no time", CreateRaceOptions{Name: "r", MaxCars: 2}, "laps or time_limit_s"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := s.CreateRace(tt.opts)
if tt.want == "" && err != nil {
t.Fatalf("expected ok, got %v", err)
}
if tt.want != "" && err == nil {
t.Fatalf("expected error containing %q, got nil", tt.want)
}
if tt.want != "" && err != nil && !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error %q does not contain %q", err.Error(), tt.want)
}
})
}
}
func TestCreateRaceEmpty(t *testing.T) {
s := NewService()
r, err := s.CreateRace(CreateRaceOptions{
Name: "open",
TrackID: "default",
MaxCars: 4,
Laps: 5,
})
if err != nil {
t.Fatalf("CreateRace: %v", err)
}
if r.Status != RaceStatusLobby {
t.Errorf("status: got %s", r.Status)
}
if len(r.DriverIDs) != 0 {
t.Errorf("expected empty drivers, got %d", len(r.DriverIDs))
}
if r.TrackID != "default" {
t.Errorf("track: got %s", r.TrackID)
}
}
func TestQuickPlayJoinsFirstRace(t *testing.T) {
s := NewService()
_, _ = s.AddDriver("d1", "Alice", "")
_, _ = s.AddDriver("d2", "Bob", "")
r1, _ := s.QuickPlay("d1", 4)
r2, err := s.QuickPlay("d2", 4)
if err != nil {
t.Fatalf("QuickPlay d2: %v", err)
}
if r2.ID != r1.ID {
t.Errorf("expected to join %s, got %s", r1.ID, r2.ID)
}
if len(r2.DriverIDs) != 2 {
t.Errorf("expected 2 drivers, got %d", len(r2.DriverIDs))
}
}
func TestSubscribeReceivesSnapshot(t *testing.T) {
s := NewService()
ch, cancel := s.Subscribe()
defer cancel()
// Subscribe pushes initial snapshot.
select {
case ev := <-ch:
if ev.kind != "snapshot" {
t.Fatalf("first event: kind=%s", ev.kind)
}
case <-time.After(time.Second):
t.Fatal("no initial snapshot")
}
}
func TestSubscribeReceivesRaceCreated(t *testing.T) {
s := NewService()
ch, cancel := s.Subscribe()
defer cancel()
<-ch // drain initial snapshot
_, _ = s.AddDriver("d1", "Alice", "")
select {
case ev := <-ch:
if ev.kind != "driver_joined" {
t.Fatalf("expected driver_joined, got %s", ev.kind)
}
case <-time.After(time.Second):
t.Fatal("no event after AddDriver")
}
}
+293
View File
@@ -0,0 +1,293 @@
// Package lobby owns the "outside any race" state of the server: which
// races are open, which drivers are connected but idle, who is the host
// of each race.
//
// The lobby is the single source of truth for matchmaking and for
// rendering the "what races are available right now" list. It does NOT
// own per-race physics — that's the Engine (internal/control).
//
// All operations are thread-safe. Mutations broadcast a snapshot event
// to subscribers (see Service.Subscribe).
package lobby
import (
"context"
"log/slog"
"sync"
"sync/atomic"
"time"
)
// RaceStatus is the lifecycle of a race.
type RaceStatus string
const (
RaceStatusLobby RaceStatus = "lobby" // waiting for drivers, no countdown yet
RaceStatusCountdown RaceStatus = "countdown" // countdown in progress
RaceStatusRacing RaceStatus = "racing" // live
RaceStatusFinished RaceStatus = "finished" // results recorded, race closes soon
)
// RaceMeta describes a single race as it appears in the lobby.
type RaceMeta struct {
ID string `json:"id"`
Name string `json:"name"`
TrackID string `json:"track_id"` // logical track identifier ("oval-a", "figure-8", ...)
MaxCars int `json:"max_cars"` // capacity (PoC: 1..4)
Laps int `json:"laps"` // total laps (0 = time-based)
TimeLimitS int `json:"time_limit_s"` // seconds; 0 = lap-based only
DriverIDs []string `json:"driver_ids"` // joined drivers
Status RaceStatus `json:"status"`
CreatedMs int64 `json:"created_ms"`
StartedMs int64 `json:"started_ms,omitempty"` // 0 if not started yet
}
// DriverStatus describes a driver as seen by the lobby.
type DriverStatus string
const (
DriverStatusIdle DriverStatus = "idle" // connected, not in any race
DriverStatusRacing DriverStatus = "racing" // in a race
DriverStatusOffline DriverStatus = "offline" // disconnected (kept briefly for reconnect grace)
)
// DriverMeta describes a driver in the lobby.
type DriverMeta struct {
ID string `json:"id"`
Name string `json:"name"`
Nickname string `json:"nickname,omitempty"` // 3-letter unique tag (A-Z)
AvatarURL string `json:"avatar_url,omitempty"`
ClanID string `json:"clan_id,omitempty"`
ClanTag string `json:"clan_tag,omitempty"` // denormalised for UI
Status DriverStatus `json:"status"`
RaceID string `json:"race_id,omitempty"` // current race if Status=racing
ConnectedMs int64 `json:"connected_ms"`
LastSeenMs int64 `json:"last_seen_ms"`
Country string `json:"country,omitempty"` // optional, for UI
AvatarHue int `json:"avatar_hue,omitempty"` // 0..360, deterministic per id
}
// Snapshot is the immutable view broadcast to clients.
type Snapshot struct {
GeneratedMs int64 `json:"generated_ms"`
Races []RaceMeta `json:"races"`
Drivers []DriverMeta `json:"drivers"`
Version uint64 `json:"version"` // monotonic, lets clients skip duplicates
}
// Stats reports lightweight counters for /stats or /lobby summary.
type Stats struct {
RacesTotal int `json:"races_total"`
RacesOpen int `json:"races_open"` // status=lobby|countdown
RacesActive int `json:"races_active"` // status=racing
DriversIdle int `json:"drivers_idle"`
DriversTotal int `json:"drivers_total"`
}
// DriverID is a type alias to avoid confusion with raw strings.
type DriverID = string
// RaceID is a type alias to avoid confusion with raw strings.
type RaceID = string
// event is an internal broadcast (not exported on the wire).
type event struct {
kind string // "snapshot" | "race_created" | "race_removed" | "race_updated" | "driver_joined" | "driver_left"
snapshot Snapshot
raceID string
driverID string
}
// Service owns lobby state. Safe for concurrent use.
type Service struct {
mu sync.RWMutex
races map[RaceID]*RaceMeta
drivers map[DriverID]*DriverMeta
version atomic.Uint64
subsMu sync.RWMutex
subs map[chan event]struct{}
stopCh chan struct{}
doneCh chan struct{}
// Hooks (set by main, called by lobby on lifecycle events).
// onRaceCreated is fired when a new race is registered in the lobby.
onRaceCreated func(RaceMeta)
// onRaceRemoved is fired when a race leaves the lobby (started, finished, deleted).
onRaceRemoved func(RaceID)
// Optional persistence sink for live races and lobby drivers. If
// non-nil, mutations are mirrored best-effort. Set via
// SetPersistence.
persist Persistence
}
// Persistence is the contract the lobby uses to mirror its in-memory
// state to durable storage. Implementations must be safe for concurrent
// use. The lobby never blocks on a persistence failure.
type Persistence interface {
UpsertRace(ctx context.Context, r RaceMeta) error
DeleteRace(ctx context.Context, raceID string) error
AddDriver(ctx context.Context, raceID, driverID string, slot int) error
RemoveDriver(ctx context.Context, raceID, driverID string) error
SetRaceStatus(ctx context.Context, raceID string, status RaceStatus, startedMs int64) error
UpsertDriver(ctx context.Context, d DriverMeta) error
DeleteDriver(ctx context.Context, driverID string) error
}
// NewService creates a fresh lobby service.
func NewService() *Service {
return &Service{
races: make(map[RaceID]*RaceMeta),
drivers: make(map[DriverID]*DriverMeta),
subs: make(map[chan event]struct{}),
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
// SetPersistence installs a write-through persistence sink. Pass nil to
// disable. After this call, mutations are mirrored to p in a detached
// goroutine; failures are silently dropped.
func (s *Service) SetPersistence(p Persistence) {
s.mu.Lock()
s.persist = p
s.mu.Unlock()
}
// SetRaceLifecycleHook installs optional callbacks (use nil to clear).
func (s *Service) SetRaceLifecycleHook(onCreated func(RaceMeta), onRemoved func(RaceID)) {
s.mu.Lock()
defer s.mu.Unlock()
s.onRaceCreated = onCreated
s.onRaceRemoved = onRemoved
}
// Subscribe returns a channel that receives events. The channel is
// closed when the service stops or the caller invokes the returned
// cancel func.
func (s *Service) Subscribe() (<-chan event, func()) {
ch := make(chan event, 16)
s.subsMu.Lock()
s.subs[ch] = struct{}{}
s.subsMu.Unlock()
// Push initial snapshot immediately so the subscriber doesn't have to
// request one explicitly.
ch <- event{kind: "snapshot", snapshot: s.snapshot()}
return ch, func() {
s.subsMu.Lock()
if _, ok := s.subs[ch]; ok {
delete(s.subs, ch)
close(ch)
}
s.subsMu.Unlock()
}
}
// Stop terminates the service. Safe to call multiple times.
func (s *Service) Stop() {
select {
case <-s.stopCh:
return
default:
close(s.stopCh)
s.subsMu.Lock()
for ch := range s.subs {
delete(s.subs, ch)
close(ch)
}
s.subsMu.Unlock()
close(s.doneCh)
}
}
// Done blocks until Stop() is called.
func (s *Service) Done() <-chan struct{} { return s.doneCh }
// snapshot builds an immutable Snapshot of current state.
func (s *Service) snapshot() Snapshot {
s.mu.RLock()
defer s.mu.RUnlock()
out := Snapshot{
GeneratedMs: time.Now().UnixMilli(),
Races: make([]RaceMeta, 0, len(s.races)),
Drivers: make([]DriverMeta, 0, len(s.drivers)),
Version: s.version.Load(),
}
now := time.Now().UnixMilli()
for _, r := range s.races {
out.Races = append(out.Races, *r)
}
for _, d := range s.drivers {
// 30-second grace for reconnect.
if d.Status == DriverStatusOffline && now-d.LastSeenMs > 30_000 {
continue
}
out.Drivers = append(out.Drivers, *d)
}
return out
}
// Snapshot returns the current state for HTTP/WS responses.
func (s *Service) Snapshot() Snapshot { return s.snapshot() }
// Stats returns lightweight counters.
func (s *Service) Stats() Stats {
s.mu.RLock()
defer s.mu.RUnlock()
out := Stats{
RacesTotal: len(s.races),
}
for _, r := range s.races {
switch r.Status {
case RaceStatusLobby, RaceStatusCountdown:
out.RacesOpen++
case RaceStatusRacing:
out.RacesActive++
}
}
for _, d := range s.drivers {
if d.Status == DriverStatusOffline {
continue
}
out.DriversTotal++
if d.Status == DriverStatusIdle {
out.DriversIdle++
}
}
return out
}
// bump increments the version and notifies subscribers.
func (s *Service) bump(kind string, raceID, driverID string) {
s.version.Add(1)
snap := s.snapshot()
ev := event{kind: kind, snapshot: snap, raceID: raceID, driverID: driverID}
s.subsMu.RLock()
defer s.subsMu.RUnlock()
for ch := range s.subs {
select {
case ch <- ev:
default:
// Slow subscriber — drop. They'll re-sync via the next snapshot.
}
}
}
// mirror dispatches a write-through persistence call if a sink is
// installed. Failures are logged but never block the caller. Always
// runs in a detached goroutine.
func (s *Service) mirror(fn func(ctx context.Context, p Persistence) error) {
s.mu.RLock()
p := s.persist
s.mu.RUnlock()
if p == nil {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := fn(ctx, p); err != nil {
slog.Warn("lobby persist failed", "err", err)
}
}()
}