mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
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:
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user