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 }