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 }
|
||||
Reference in New Issue
Block a user