Add driver profile stats API, nickname update editing, and security checks

This commit is contained in:
2026-07-17 23:18:16 +04:00
parent a5d2ee32c6
commit 5d2e0d59df
14 changed files with 882 additions and 403 deletions
+2 -1
View File
@@ -82,7 +82,8 @@
- CRUD: - CRUD:
- `POST/GET /api/clans`, `GET/PUT/DELETE /api/clans/{id}`, `GET /api/clans/by-tag/{tag}` - `POST/GET /api/clans`, `GET/PUT/DELETE /api/clans/{id}`, `GET /api/clans/by-tag/{tag}`
- `POST/GET /api/drivers`, `GET/PUT/DELETE /api/drivers/{id}`, `GET /api/drivers/by-nick/{nick}` - `POST/GET /api/drivers`, `GET/PUT/DELETE /api/drivers/{id}`, `GET /api/drivers/by-nick/{nick}`
- `PUT /api/drivers/{id}` принимает `clan_id` как `*string` (nil = оставить, "" = очистить, иначе = назначить). - `GET /api/drivers/{id}/profile` — профиль с агрегированной статистикой (всего стартов/финишей, победы, подиумы, лучшее время круга, суммарное время на треке) и сводками лучшей и последней завершенной гонки. Можно вызвать как `/api/drivers/me/profile` для получения профиля авторизованного гонщика.
- `PUT /api/drivers/{id}` принимает `nickname` (3 уникальные заглавные латинские буквы), `name`, `avatar_url` и `clan_id` как `*string` (nil = оставить, "" = очистить, иначе = назначить). Редактирование и удаление чужого профиля запрещено при `DevMode = false`.
- Сидер (через `--seed-races`) заполняет 3 клана и 8 водителей, треки берутся из `tracks` каталога (никаких выдуманных id в `finished_races`/`race_plans`). - Сидер (через `--seed-races`) заполняет 3 клана и 8 водителей, треки берутся из `tracks` каталога (никаких выдуманных id в `finished_races`/`race_plans`).
- Podium в `LobbyRace` заполняется только для finished-гонок (top-3: position, driver_id, name, total_time_ms). - Podium в `LobbyRace` заполняется только для finished-гонок (top-3: position, driver_id, name, total_time_ms).
+403 -394
View File
@@ -2,6 +2,53 @@
This file contains a compiled map of all packages, structures, interfaces, and public functions in the `server` directory. It is optimized for token-efficiency so AI agents and humans can understand the codebase layout without reading all files. This file contains a compiled map of all packages, structures, interfaces, and public functions in the `server` directory. It is optimized for token-efficiency so AI agents and humans can understand the codebase layout without reading all files.
## Package `main` (`cmd/poc-server`)
HTTP handlers for the catalog (tracks + cars).
All HTTP routes live under /api/*:
GET /api/tracks list tracks (optional ?scope=system|public|private&tag=)
GET /api/tracks/{id} get a single track
POST /api/tracks create a track
PUT /api/tracks/{id} update a track
DELETE /api/tracks/{id} delete a track
GET /api/cars list cars (optional ?scope=...&owner_id=...)
GET /api/cars/{id} get a single car
POST /api/cars create a car
PUT /api/cars/{id} update a car
DELETE /api/cars/{id} delete a car
GET /api/catalog combined snapshot + summary
WebSocket access is wired up directly in main.go's readPump switch.
### Structs
- **struct Hub**
- **struct UDPVideoService**
- **struct WebRTCService**
### Functions
- func NewHub(*slog.Logger) *Hub
- func NewUDPVideoService(*slog.Logger, string, int) *UDPVideoService
- func NewWebRTCService(*slog.Logger, *control.Engine, *lobby.Service, *UDPVideoService, *catalog.Service) *WebRTCService
### Methods
- func (*catalogBroadcaster) Run()
- func (*catalogBroadcaster) Stop()
- func (*Hub) BroadcastSnapshot(transport.RaceSnapshot)
- func (*statusRecorder) WriteHeader(int)
- func (*statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error)
- func (*UDPVideoService) Start(context.Context, *sync.WaitGroup)
- func (*UDPVideoService) SendControlPacket(uint8, []byte) error
- func (*UDPVideoService) SendCommand(uint8, string) error
- func (*UDPVideoService) StreamHandler() http.HandlerFunc
- func (*UDPVideoService) ControlHandler() http.HandlerFunc
- func (*WebRTCService) Start(context.Context, *sync.WaitGroup)
- func (*WebRTCService) ConnectHandler() http.HandlerFunc
- func (*WebRTCService) CloseSession(string)
- func (*WebRTCService) CloseAll()
---
## Package `clans` (`internal/clans`) ## Package `clans` (`internal/clans`)
Package clans owns persistence and business logic for clans. Package clans owns persistence and business logic for clans.
@@ -43,23 +90,6 @@ nullable FK (see internal/drivers).
--- ---
## Package `config` (`internal/config`)
Package config loads runtime configuration from environment variables.
On startup Load() reads a .env file (if present) from the current
working directory and merges its values into the process environment.
Existing process env vars take precedence over .env so deployments
can override .env without modifying the file.
### Structs
- **struct Config** { HTTPAddr, TickRate, TickInterval, LogLevel, MaxClients, SnapshotRate, DevMode, DatabaseURL, JWTSecret, UDPVideoAddr, ESPCmdPort }
### Functions
- func Load() (*Config, error)
---
## Package `lobby` (`internal/lobby`) ## Package `lobby` (`internal/lobby`)
Package lobby owns the "outside any race" state of the server: which Package lobby owns the "outside any race" state of the server: which
@@ -114,84 +144,6 @@ to subscribers (see Service.Subscribe).
--- ---
## Package `main` (`cmd/poc-server`)
HTTP handlers for the catalog (tracks + cars).
All HTTP routes live under /api/*:
GET /api/tracks list tracks (optional ?scope=system|public|private&tag=)
GET /api/tracks/{id} get a single track
POST /api/tracks create a track
PUT /api/tracks/{id} update a track
DELETE /api/tracks/{id} delete a track
GET /api/cars list cars (optional ?scope=...&owner_id=...)
GET /api/cars/{id} get a single car
POST /api/cars create a car
PUT /api/cars/{id} update a car
DELETE /api/cars/{id} delete a car
GET /api/catalog combined snapshot + summary
WebSocket access is wired up directly in main.go's readPump switch.
### Structs
- **struct Hub**
- **struct UDPVideoService**
- **struct WebRTCService**
### Functions
- func NewHub(*slog.Logger) *Hub
- func NewUDPVideoService(*slog.Logger, string, int) *UDPVideoService
- func NewWebRTCService(*slog.Logger, *control.Engine, *lobby.Service, *UDPVideoService, *catalog.Service) *WebRTCService
### Methods
- func (*catalogBroadcaster) Run()
- func (*catalogBroadcaster) Stop()
- func (*Hub) BroadcastSnapshot(transport.RaceSnapshot)
- func (*statusRecorder) WriteHeader(int)
- func (*statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error)
- func (*UDPVideoService) Start(context.Context, *sync.WaitGroup)
- func (*UDPVideoService) SendControlPacket(uint8, []byte) error
- func (*UDPVideoService) SendCommand(uint8, string) error
- func (*UDPVideoService) StreamHandler() http.HandlerFunc
- func (*UDPVideoService) ControlHandler() http.HandlerFunc
- func (*WebRTCService) Start(context.Context, *sync.WaitGroup)
- func (*WebRTCService) ConnectHandler() http.HandlerFunc
- func (*WebRTCService) CloseSession(string)
- func (*WebRTCService) CloseAll()
---
## Package `control` (`internal/control`)
Package control owns the authoritative race state.
For PoC, the physics model is intentionally simple (no tire slip, no
collision): each car integrates position from (throttle, brake, steering).
Realistic physics, anti-cheat checks, and CV-based ground truth will be
added in MVP / production phases.
### Structs
- **struct Car** { ID, DriverID, DriverName, DeviceID, X, Y, Heading, Speed, Lap, Sector, LastLapMs, BestLapMs, DNF, AppliedSteering, AppliedThrottle, AppliedBrake }
- **struct Engine**
### Functions
- func NewEngine(int) *Engine
### Methods
- func (RacePhase) String() string
- func (*Car) ApplyInput(transport.InputState, float64)
- func (*Car) Snapshot() transport.CarInfo
- func (*Engine) Snapshots() ?
- func (*Engine) Phase() RacePhase
- func (*Engine) SetPhase(RacePhase)
- func (*Engine) AddCar(string, string, int, *int) (*Car, error)
- func (*Engine) RemoveCar(string)
- func (*Engine) GetCarByDriver(string) *Car
- func (*Engine) Run(context.Context)
- func (*Engine) Stop()
---
## Package `races` (`internal/races`) ## Package `races` (`internal/races`)
Package races owns the cross-cutting "race list" surface: keyset-paginated Package races owns the cross-cutting "race list" surface: keyset-paginated
@@ -231,6 +183,8 @@ The package composes two storage backends:
- **struct RacePlan** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, StartAtMs, IntervalS, Count, Enabled, CreatedMs, UpdatedMs, NextFireMs, FiresDone } - **struct RacePlan** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, StartAtMs, IntervalS, Count, Enabled, CreatedMs, UpdatedMs, NextFireMs, FiresDone }
- **struct QueueEntry** { DriverID, RaceID, PlanID, EnqueuedMs } - **struct QueueEntry** { DriverID, RaceID, PlanID, EnqueuedMs }
- **struct PgStore** - **struct PgStore**
- **struct DriverStats** { TotalRacesStarted, TotalRacesFinished, Wins, Podiums, BestLapMs, TotalPlaytimeMs }
- **struct DriverRaceSummary** { RaceID, RaceName, FinishedMs, Position, TotalTimeMs, BestLapMs, TrackID, TrackName }
### Functions ### Functions
- func DecodeCursor(string) (Cursor, error) - func DecodeCursor(string) (Cursor, error)
@@ -279,6 +233,7 @@ The package composes two storage backends:
- func (*Service) GetCalendarLeaderboard(context.Context, int, string) ([]TrackCalendarLeaderboard, error) - func (*Service) GetCalendarLeaderboard(context.Context, int, string) ([]TrackCalendarLeaderboard, error)
- func (*Service) GetRace(context.Context, string) (FinishedRace, error) - func (*Service) GetRace(context.Context, string) (FinishedRace, error)
- func (*Service) GetLastFinished(context.Context) (FinishedRace, error) - func (*Service) GetLastFinished(context.Context) (FinishedRace, error)
- func (*Service) GetDriverProfileStats(context.Context, string) (DriverStats, *DriverRaceSummary, *DriverRaceSummary, error)
- func (*Scheduler) Run(context.Context) - func (*Scheduler) Run(context.Context)
- func (*Scheduler) Stop() - func (*Scheduler) Stop()
- func (*Scheduler) Done() ? - func (*Scheduler) Done() ?
@@ -315,6 +270,355 @@ The package composes two storage backends:
- func (*PgStore) ListQueueByRace(context.Context, string) ([]QueueEntry, error) - func (*PgStore) ListQueueByRace(context.Context, string) ([]QueueEntry, error)
- func (*PgStore) CountQueueByRace(context.Context, string) (int, error) - func (*PgStore) CountQueueByRace(context.Context, string) (int, error)
- func (FinishedRace) ToLobbyMeta() lobby.RaceMeta - func (FinishedRace) ToLobbyMeta() lobby.RaceMeta
- func (*PgStore) GetDriverStats(context.Context, string) (DriverStats, error)
- func (*PgStore) GetDriverLastRace(context.Context, string) (*DriverRaceSummary, error)
- func (*PgStore) GetDriverBestRace(context.Context, string) (*DriverRaceSummary, error)
---
## Package `main` (`scripts/genseed`)
Package main contains the genseed utility. It is built into a standalone
binary via `go build ./scripts/genseed` and is not part of the runtime.
Usage:
go run ./scripts/genseed seeds.json > 002_seed.sql
It reads the JSON dump produced by `DUMP_SEEDS=1 go test ./internal/catalog`
and emits ON CONFLICT-safe INSERTs for the catalog tables.
---
## Package `main` (`scripts/genseed-json`)
Package main contains a small helper that prints the default seeds
(5 F1 tracks + 5 F1 cars) as JSON to stdout. It is run as:
go run ./scripts/genseed-json > seeds.json
which is then fed into scripts/genseed to regenerate the SQL.
---
## Package `main` (`scripts/graphify`)
Package main is the graphify utility. It walks the codebase, parses Go AST,
and outputs a highly compact symbol summary to server/CODEBASE.md.
This is used for token savings and helping developers/AI agents quickly map out imports and structures.
### Structs
- **struct PackageSummary** { Name, Path, Doc, Structs, Interfaces, Functions, Methods }
---
## Package `auth` (`internal/auth`)
Package auth handles Supabase JWT validation and HTTP middleware.
### Structs
- **struct Claims** { DriverID, Email }
### Functions
- func ValidateJWT(string, string) (Claims, error)
- func Middleware(string, bool) func
- func DriverIDFromContext(context.Context) (string, bool)
- func EmailFromContext(context.Context) (string, bool)
---
## Package `main` (`.`)
Smoke test: ws client connects, exchanges a few messages, exits.
Run: go run ./smoke_test.go
---
## Package `seeddefs` (`internal/catalog/seeddefs`)
Package seeddefs holds the canonical seed definitions for tracks and
cars. It lives in its own package (no postgres dependencies) so both
the catalog tests and the scripts/genseed SQL generator can consume
the same source-of-truth data.
### Structs
- **struct Waypoint** { X, Y, HeadingRad, SpeedMs, Curvature }
- **struct Bounds** { MinX, MinY, MaxX, MaxY, LengthM, WidthM }
- **struct Track** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline, Bounds, BestLapMs, BestLapHolder }
- **struct Chassis** { Model, Material, Printed, WheelbaseMm, TrackMm }
- **struct Motor** { Kind, Class, KV, PowerW }
- **struct Battery** { VoltageV, CapacityMah, Cells, Chemistry }
- **struct Car** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, TopSpeedMs, ColorHex, Active, TotalDistanceM, TotalRaces, TotalLaps, BestLapMs }
### Functions
- func DefaultTracks() []Track
- func DefaultCars() []Car
- func Monaco() Track
- func BarcelonaCatalunya() Track
- func AustriaRedBullRing() Track
- func GreatBritainSilverstone() Track
- func BelgiumSpa() Track
- func RedBullRB20() Car
- func FerrariSF24() Car
- func McLarenMCL38() Car
- func MercedesW15() Car
- func AstonMartinAMR24() Car
- func ComputeBounds([]Waypoint) Bounds
---
## Package `control` (`internal/control`)
Package control owns the authoritative race state.
For PoC, the physics model is intentionally simple (no tire slip, no
collision): each car integrates position from (throttle, brake, steering).
Realistic physics, anti-cheat checks, and CV-based ground truth will be
added in MVP / production phases.
### Structs
- **struct Car** { ID, DriverID, DriverName, DeviceID, X, Y, Heading, Speed, Lap, Sector, LastLapMs, BestLapMs, DNF, AppliedSteering, AppliedThrottle, AppliedBrake }
- **struct Engine**
### Functions
- func NewEngine(int) *Engine
### Methods
- func (RacePhase) String() string
- func (*Car) ApplyInput(transport.InputState, float64)
- func (*Car) Snapshot() transport.CarInfo
- func (*Engine) Snapshots() ?
- func (*Engine) Phase() RacePhase
- func (*Engine) SetPhase(RacePhase)
- func (*Engine) AddCar(string, string, int, *int) (*Car, error)
- func (*Engine) RemoveCar(string)
- func (*Engine) GetCarByDriver(string) *Car
- func (*Engine) Run(context.Context)
- func (*Engine) Stop()
---
## Package `drivers` (`internal/drivers`)
Package drivers owns persistence and business logic for drivers
(racers). Each driver has a unique 3-letter nickname (A-Z,
uppercase, exactly 3 chars), a name, an optional avatar URL and an
optional clan reference.
### Interfaces
- **interface Store** { Exec, Create, Get, GetByNickname, List, Delete, Update, Count }
### Structs
- **struct Service**
- **struct CreateInput** { ID, Nickname, Name, AvatarURL, ClanID }
- **struct UpdateInput** { Nickname, Name, AvatarURL, ClanID }
- **struct Driver** { ID, Nickname, Name, AvatarURL, ClanID, CreatedMs, UpdatedMs }
- **struct PgStore**
### Functions
- func NewService(Store) *Service
- func IsValidNickname(string) bool
- func NewPgStore(*pgxpool.Pool) *PgStore
### Methods
- func (*Service) Create(context.Context, CreateInput) (Driver, error)
- func (*Service) Get(context.Context, string) (Driver, error)
- func (*Service) GetByNickname(context.Context, string) (Driver, error)
- func (*Service) List(context.Context, int, int, string) ([]Driver, error)
- func (*Service) Update(context.Context, string, UpdateInput) (Driver, error)
- func (*Service) Delete(context.Context, string) error
- func (*PgStore) Exec(context.Context, string) (int64, error)
- func (*PgStore) Create(context.Context, Driver) error
- func (*PgStore) Get(context.Context, string) (Driver, error)
- func (*PgStore) GetByNickname(context.Context, string) (Driver, error)
- func (*PgStore) List(context.Context, int, int, string) ([]Driver, error)
- func (*PgStore) Update(context.Context, Driver) error
- func (*PgStore) Delete(context.Context, string) error
- func (*PgStore) Count(context.Context) (int, error)
---
## Package `realtime` (`internal/realtime`)
Package realtime manages WebSocket connections: registration, fan-out of
race snapshots, and per-client send queues with backpressure handling.
### Structs
- **struct Client** { ID, Send, SessionID, Done, DriverID, LastUDPCommand }
- **struct Hub**
- **struct Stats** { Connections, DropsTotal }
### Functions
- func NewHub() *Hub
- func MustEnvelope(transport.MessageType, any) *transport.Envelope
### Methods
- func (*Client) Close()
- func (*Hub) Stats() Stats
- func (*Hub) Register(*Client)
- func (*Hub) Unregister(*Client)
- func (*Hub) Run(context.Context)
- func (*Hub) Stop()
- func (*Hub) Publish(*transport.Envelope)
---
## Package `catalog` (`internal/catalog`)
Package catalog owns the static configuration entities: tracks and
cars. Both are first-class resources that drivers browse, pick, and
(in later phases) author.
PoC: in-memory cache backed by a pluggable Store. The runtime uses
postgres.PgStore so tracks / cars survive restarts. The cache is the
source of truth for read paths; the Store is the source of truth for
durability. Mutations go through the Store, then refresh the cache.
Design constraints come from the user's apartment layout:
- largest room: 4.46 m × 3.19 m (446 × 319 cm)
- car scale: 1/27 (Compact)
- printer: Bambu A1 (220×220×250 mm build volume)
Tracks are sized to fit a 4.5 × 3.2 m bounding box with a safety
margin, which keeps them physically realizable in the apartment.
Cars are sized to a 1/27 touring envelope (~80 × 35 × 25 mm).
### Interfaces
- **interface Store** { Load, UpsertTrack, DeleteTrack, UpsertCar, DeleteCar, UpdateCarStats, GetTrackCalendar, CreateTrackCalendar, DeleteTrackCalendar }
### Structs
- **struct CreateTrackOptions** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline }
- **struct UpdateTrackOptions** { Name, Description, Visibility, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline }
- **struct CreateCarOptions** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, TopSpeedMs, ColorHex, AvatarURL, Active, DeviceID }
- **struct UpdateCarOptions** { Name, Visibility, LengthMm, WidthMm, HeightMm, WeightG, TopSpeedMs, ColorHex, Active, Chassis, Motor, Battery, Drive, DeviceID }
- **struct Waypoint** { X, Y, HeadingRad, SpeedMs, Curvature }
- **struct Bounds** { MinX, MinY, MaxX, MaxY, LengthM, WidthM }
- **struct TrackMeta** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Bounds, Surface, Tags, Centerline, BestLapMs, BestLapHolder, CreatedMs, UpdatedMs }
- **struct TrackCalendarEntry** { ID, TrackID, StartAt, EndAt }
- **struct MotorSpec** { Kind, Class, KV, PowerW }
- **struct BatterySpec** { VoltageV, CapacityMah, Cells, Chemistry }
- **struct ChassisSpec** { Model, Material, Printed, WheelbaseMm, TrackMm }
- **struct CarMeta** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, DeviceID, TopSpeedMs, ColorHex, AvatarURL, Active, TotalDistanceM, TotalRaces, TotalLaps, BestLapMs, CreatedMs, UpdatedMs }
- **struct Snapshot** { GeneratedMs, Version, Tracks, Cars }
- **struct Stats** { TracksTotal, TracksSystem, TracksPublic, TracksPrivate, CarsTotal, CarsSystem, CarsPublic, CarsPrivate, CarsActive }
- **struct Event** { Kind, TrackID, CarID, Snapshot }
- **struct Service**
### Functions
- func NewService(context.Context, Store) (*Service, error)
### Methods
- func (CreateTrackOptions) Validate() error
- func (*Service) CreateTrack(context.Context, CreateTrackOptions) (TrackMeta, error)
- func (*Service) UpdateTrack(context.Context, string, UpdateTrackOptions) (TrackMeta, error)
- func (*Service) DeleteTrack(context.Context, string) error
- func (CreateCarOptions) Validate() error
- func (*Service) CreateCar(context.Context, CreateCarOptions) (CarMeta, error)
- func (*Service) UpdateCar(context.Context, string, UpdateCarOptions) (CarMeta, error)
- func (*Service) DeleteCar(context.Context, string) error
- func (*Service) SetCarStats(context.Context, string, func) error
- func (*Service) ListTracksByVisibility(Visibility) []TrackMeta
- func (*Service) ListCarsByVisibility(Visibility) []CarMeta
- func (*Service) ListCarsByOwner(string) []CarMeta
- func (*Service) GetTrackCalendar(context.Context) ([]TrackCalendarEntry, error)
- func (*Service) CreateTrackCalendar(context.Context, string, time.Time, time.Time) (TrackCalendarEntry, error)
- func (*Service) DeleteTrackCalendar(context.Context, int) error
- func (*memoryStore) Load(context.Context) ([]TrackMeta, []CarMeta, error)
- func (*memoryStore) UpsertTrack(context.Context, TrackMeta) error
- func (*memoryStore) DeleteTrack(context.Context, string) error
- func (*memoryStore) UpsertCar(context.Context, CarMeta) error
- func (*memoryStore) DeleteCar(context.Context, string) error
- func (*memoryStore) UpdateCarStats(context.Context, string, func) error
- func (*memoryStore) GetTrackCalendar(context.Context) ([]TrackCalendarEntry, error)
- func (*memoryStore) CreateTrackCalendar(context.Context, TrackCalendarEntry) (TrackCalendarEntry, error)
- func (*memoryStore) DeleteTrackCalendar(context.Context, int) error
- func (*Service) Subscribe() (?, func)
- func (*Service) Stop()
- func (*Service) Done() ?
- func (*Service) Snapshot() Snapshot
- func (*Service) Stats() Stats
- func (*Service) ListTracks() []TrackMeta
- func (*Service) ListCars() []CarMeta
- func (*Service) GetTrack(string) (TrackMeta, bool)
- func (*Service) GetCar(string) (CarMeta, bool)
---
## Package `config` (`internal/config`)
Package config loads runtime configuration from environment variables.
On startup Load() reads a .env file (if present) from the current
working directory and merges its values into the process environment.
Existing process env vars take precedence over .env so deployments
can override .env without modifying the file.
### Structs
- **struct Config** { HTTPAddr, TickRate, TickInterval, LogLevel, MaxClients, SnapshotRate, DevMode, DatabaseURL, JWTSecret, UDPVideoAddr, ESPCmdPort }
### Functions
- func Load() (*Config, error)
---
## Package `seed` (`internal/races/seed`)
### Structs
- **struct DefaultCounts** { Finished, Live, Plans }
- **struct Options** { Counts, Seed, Reset, Now }
- **struct Summary** { FinishedInserted, LiveCreated, PlansInserted, QueueInserted, Duration }
- **struct Runner**
### Functions
- func NewRunner(*races.PgStore, *lobby.Service, *races.LiveStore, *clans.PgStore, *drivers.PgStore) *Runner
### Methods
- func (*Runner) Run(context.Context, Options) (Summary, error)
---
## Package `stats` (`internal/stats`)
Package stats collects and exposes server and per-driver metrics.
PoC: in-memory only, atomic counters where possible, mutex around
driver and race records. The data lives for the lifetime of the
process. A future storage layer will swap this for Postgres/Redis.
Public surface is split into:
- lifecycle hooks (Connect/Disconnect/Race*/Lap*/Input*/RTT) —
called from cmd/poc-server and from internal/control and lobby
- Snapshot() — full server snapshot for /stats/detailed
- DriverProfile(id) — per-driver summary
- RecentLaps(n) — last N laps across all races
### Structs
- **struct ServerStats** { StartedMs, UptimeMs, CurrentClients, PeakClients, TotalConnections, TotalDisconnects, TotalRacesCreated, TotalRacesStarted, TotalRacesFinished, TotalLapsRecorded, TotalInputsRecv, TotalSnapshotsSent, SnapshotsDropped, AvgRTTMs, RecentRTTs }
- **struct DriverProfile** { ID, Name, TotalRacesStarted, TotalRacesFinished, TotalLaps, BestLapMs, LastLapMs, TotalDistanceM, TotalPlaytimeMs, Wins, Podiums, LastSeenMs }
- **struct LapRecord** { RaceID, DriverID, LapMs, Sector1Ms, Sector2Ms, Sector3Ms, Position, TSMs }
- **struct RaceResult** { RaceID, DriverID, FinishedAtMs, Position, TotalLaps, BestLapMs, TotalTimeMs, DNF }
- **struct Collector**
### Functions
- func NewCollector() *Collector
### Methods
- func (*Collector) Start()
- func (*Collector) OnConnect(string, string)
- func (*Collector) OnDisconnect(string, int64)
- func (*Collector) OnRaceCreated()
- func (*Collector) OnRaceStarted([]string)
- func (*Collector) OnRaceFinished(RaceResult)
- func (*Collector) OnLapRecorded(LapRecord)
- func (*Collector) OnInputProcessed()
- func (*Collector) OnSnapshotSent(uint64, uint64)
- func (*Collector) OnRTT(int64)
- func (*Collector) OnDistance(string, float64)
- func (*Collector) Server() ServerStats
- func (*Collector) Driver(string, string) DriverProfile
- func (*Collector) Drivers() []DriverProfile
- func (*Collector) RecentLaps(int) []LapRecord
- func (*Collector) RecentResults(int) []RaceResult
--- ---
@@ -425,7 +729,10 @@ TODO PoC: replace JSON with protobuf-generated structs (see
- **struct RacePlanListResponse** { Items, NextCursor, Count } - **struct RacePlanListResponse** { Items, NextCursor, Count }
- **struct Driver** { ID, Nickname, Name, AvatarURL, ClanID, CreatedMs, UpdatedMs } - **struct Driver** { ID, Nickname, Name, AvatarURL, ClanID, CreatedMs, UpdatedMs }
- **struct DriverCreateRequest** { ID, Nickname, Name, AvatarURL, ClanID } - **struct DriverCreateRequest** { ID, Nickname, Name, AvatarURL, ClanID }
- **struct DriverUpdateRequest** { Name, AvatarURL, ClanID } - **struct DriverUpdateRequest** { Nickname, Name, AvatarURL, ClanID }
- **struct DriverProfileResponse** { Driver, Stats, LastRace, BestRace }
- **struct DriverStats** { TotalRacesStarted, TotalRacesFinished, Wins, Podiums, BestLapMs, TotalPlaytimeMs }
- **struct DriverRaceSummary** { RaceID, RaceName, FinishedMs, Position, TotalTimeMs, BestLapMs, TrackID, TrackName }
- **struct DriverListResponse** { Items, Count } - **struct DriverListResponse** { Items, Count }
- **struct Clan** { ID, Tag, Name, AvatarURL, CreatedMs, UpdatedMs } - **struct Clan** { ID, Tag, Name, AvatarURL, CreatedMs, UpdatedMs }
- **struct ClanCreateRequest** { ID, Tag, Name, AvatarURL } - **struct ClanCreateRequest** { ID, Tag, Name, AvatarURL }
@@ -455,301 +762,3 @@ TODO PoC: replace JSON with protobuf-generated structs (see
--- ---
## Package `auth` (`internal/auth`)
Package auth handles Supabase JWT validation and HTTP middleware.
### Structs
- **struct Claims** { DriverID, Email }
### Functions
- func ValidateJWT(string, string) (Claims, error)
- func Middleware(string, bool) func
- func DriverIDFromContext(context.Context) (string, bool)
- func EmailFromContext(context.Context) (string, bool)
---
## Package `drivers` (`internal/drivers`)
Package drivers owns persistence and business logic for drivers
(racers). Each driver has a unique 3-letter nickname (A-Z,
uppercase, exactly 3 chars), a name, an optional avatar URL and an
optional clan reference.
### Interfaces
- **interface Store** { Exec, Create, Get, GetByNickname, List, Delete, Update, Count }
### Structs
- **struct Service**
- **struct CreateInput** { ID, Nickname, Name, AvatarURL, ClanID }
- **struct UpdateInput** { Name, AvatarURL, ClanID }
- **struct Driver** { ID, Nickname, Name, AvatarURL, ClanID, CreatedMs, UpdatedMs }
- **struct PgStore**
### Functions
- func NewService(Store) *Service
- func IsValidNickname(string) bool
- func NewPgStore(*pgxpool.Pool) *PgStore
### Methods
- func (*Service) Create(context.Context, CreateInput) (Driver, error)
- func (*Service) Get(context.Context, string) (Driver, error)
- func (*Service) GetByNickname(context.Context, string) (Driver, error)
- func (*Service) List(context.Context, int, int, string) ([]Driver, error)
- func (*Service) Update(context.Context, string, UpdateInput) (Driver, error)
- func (*Service) Delete(context.Context, string) error
- func (*PgStore) Exec(context.Context, string) (int64, error)
- func (*PgStore) Create(context.Context, Driver) error
- func (*PgStore) Get(context.Context, string) (Driver, error)
- func (*PgStore) GetByNickname(context.Context, string) (Driver, error)
- func (*PgStore) List(context.Context, int, int, string) ([]Driver, error)
- func (*PgStore) Update(context.Context, Driver) error
- func (*PgStore) Delete(context.Context, string) error
- func (*PgStore) Count(context.Context) (int, error)
---
## Package `realtime` (`internal/realtime`)
Package realtime manages WebSocket connections: registration, fan-out of
race snapshots, and per-client send queues with backpressure handling.
### Structs
- **struct Client** { ID, Send, SessionID, Done, DriverID, LastUDPCommand }
- **struct Hub**
- **struct Stats** { Connections, DropsTotal }
### Functions
- func NewHub() *Hub
- func MustEnvelope(transport.MessageType, any) *transport.Envelope
### Methods
- func (*Client) Close()
- func (*Hub) Stats() Stats
- func (*Hub) Register(*Client)
- func (*Hub) Unregister(*Client)
- func (*Hub) Run(context.Context)
- func (*Hub) Stop()
- func (*Hub) Publish(*transport.Envelope)
---
## Package `main` (`.`)
Smoke test: ws client connects, exchanges a few messages, exits.
Run: go run ./smoke_test.go
---
## Package `catalog` (`internal/catalog`)
Package catalog owns the static configuration entities: tracks and
cars. Both are first-class resources that drivers browse, pick, and
(in later phases) author.
PoC: in-memory cache backed by a pluggable Store. The runtime uses
postgres.PgStore so tracks / cars survive restarts. The cache is the
source of truth for read paths; the Store is the source of truth for
durability. Mutations go through the Store, then refresh the cache.
Design constraints come from the user's apartment layout:
- largest room: 4.46 m × 3.19 m (446 × 319 cm)
- car scale: 1/27 (Compact)
- printer: Bambu A1 (220×220×250 mm build volume)
Tracks are sized to fit a 4.5 × 3.2 m bounding box with a safety
margin, which keeps them physically realizable in the apartment.
Cars are sized to a 1/27 touring envelope (~80 × 35 × 25 mm).
### Interfaces
- **interface Store** { Load, UpsertTrack, DeleteTrack, UpsertCar, DeleteCar, UpdateCarStats, GetTrackCalendar, CreateTrackCalendar, DeleteTrackCalendar }
### Structs
- **struct CreateTrackOptions** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline }
- **struct UpdateTrackOptions** { Name, Description, Visibility, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline }
- **struct CreateCarOptions** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, TopSpeedMs, ColorHex, AvatarURL, Active, DeviceID }
- **struct UpdateCarOptions** { Name, Visibility, LengthMm, WidthMm, HeightMm, WeightG, TopSpeedMs, ColorHex, Active, Chassis, Motor, Battery, Drive, DeviceID }
- **struct Waypoint** { X, Y, HeadingRad, SpeedMs, Curvature }
- **struct Bounds** { MinX, MinY, MaxX, MaxY, LengthM, WidthM }
- **struct TrackMeta** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Bounds, Surface, Tags, Centerline, BestLapMs, BestLapHolder, CreatedMs, UpdatedMs }
- **struct TrackCalendarEntry** { ID, TrackID, StartAt, EndAt }
- **struct MotorSpec** { Kind, Class, KV, PowerW }
- **struct BatterySpec** { VoltageV, CapacityMah, Cells, Chemistry }
- **struct ChassisSpec** { Model, Material, Printed, WheelbaseMm, TrackMm }
- **struct CarMeta** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, DeviceID, TopSpeedMs, ColorHex, AvatarURL, Active, TotalDistanceM, TotalRaces, TotalLaps, BestLapMs, CreatedMs, UpdatedMs }
- **struct Snapshot** { GeneratedMs, Version, Tracks, Cars }
- **struct Stats** { TracksTotal, TracksSystem, TracksPublic, TracksPrivate, CarsTotal, CarsSystem, CarsPublic, CarsPrivate, CarsActive }
- **struct Event** { Kind, TrackID, CarID, Snapshot }
- **struct Service**
### Functions
- func NewService(context.Context, Store) (*Service, error)
### Methods
- func (CreateTrackOptions) Validate() error
- func (*Service) CreateTrack(context.Context, CreateTrackOptions) (TrackMeta, error)
- func (*Service) UpdateTrack(context.Context, string, UpdateTrackOptions) (TrackMeta, error)
- func (*Service) DeleteTrack(context.Context, string) error
- func (CreateCarOptions) Validate() error
- func (*Service) CreateCar(context.Context, CreateCarOptions) (CarMeta, error)
- func (*Service) UpdateCar(context.Context, string, UpdateCarOptions) (CarMeta, error)
- func (*Service) DeleteCar(context.Context, string) error
- func (*Service) SetCarStats(context.Context, string, func) error
- func (*Service) ListTracksByVisibility(Visibility) []TrackMeta
- func (*Service) ListCarsByVisibility(Visibility) []CarMeta
- func (*Service) ListCarsByOwner(string) []CarMeta
- func (*Service) GetTrackCalendar(context.Context) ([]TrackCalendarEntry, error)
- func (*Service) CreateTrackCalendar(context.Context, string, time.Time, time.Time) (TrackCalendarEntry, error)
- func (*Service) DeleteTrackCalendar(context.Context, int) error
- func (*memoryStore) Load(context.Context) ([]TrackMeta, []CarMeta, error)
- func (*memoryStore) UpsertTrack(context.Context, TrackMeta) error
- func (*memoryStore) DeleteTrack(context.Context, string) error
- func (*memoryStore) UpsertCar(context.Context, CarMeta) error
- func (*memoryStore) DeleteCar(context.Context, string) error
- func (*memoryStore) UpdateCarStats(context.Context, string, func) error
- func (*memoryStore) GetTrackCalendar(context.Context) ([]TrackCalendarEntry, error)
- func (*memoryStore) CreateTrackCalendar(context.Context, TrackCalendarEntry) (TrackCalendarEntry, error)
- func (*memoryStore) DeleteTrackCalendar(context.Context, int) error
- func (*Service) Subscribe() (?, func)
- func (*Service) Stop()
- func (*Service) Done() ?
- func (*Service) Snapshot() Snapshot
- func (*Service) Stats() Stats
- func (*Service) ListTracks() []TrackMeta
- func (*Service) ListCars() []CarMeta
- func (*Service) GetTrack(string) (TrackMeta, bool)
- func (*Service) GetCar(string) (CarMeta, bool)
---
## Package `seed` (`internal/races/seed`)
### Structs
- **struct DefaultCounts** { Finished, Live, Plans }
- **struct Options** { Counts, Seed, Reset, Now }
- **struct Summary** { FinishedInserted, LiveCreated, PlansInserted, QueueInserted, Duration }
- **struct Runner**
### Functions
- func NewRunner(*races.PgStore, *lobby.Service, *races.LiveStore, *clans.PgStore, *drivers.PgStore) *Runner
### Methods
- func (*Runner) Run(context.Context, Options) (Summary, error)
---
## Package `stats` (`internal/stats`)
Package stats collects and exposes server and per-driver metrics.
PoC: in-memory only, atomic counters where possible, mutex around
driver and race records. The data lives for the lifetime of the
process. A future storage layer will swap this for Postgres/Redis.
Public surface is split into:
- lifecycle hooks (Connect/Disconnect/Race*/Lap*/Input*/RTT) —
called from cmd/poc-server and from internal/control and lobby
- Snapshot() — full server snapshot for /stats/detailed
- DriverProfile(id) — per-driver summary
- RecentLaps(n) — last N laps across all races
### Structs
- **struct ServerStats** { StartedMs, UptimeMs, CurrentClients, PeakClients, TotalConnections, TotalDisconnects, TotalRacesCreated, TotalRacesStarted, TotalRacesFinished, TotalLapsRecorded, TotalInputsRecv, TotalSnapshotsSent, SnapshotsDropped, AvgRTTMs, RecentRTTs }
- **struct DriverProfile** { ID, Name, TotalRacesStarted, TotalRacesFinished, TotalLaps, BestLapMs, LastLapMs, TotalDistanceM, TotalPlaytimeMs, Wins, Podiums, LastSeenMs }
- **struct LapRecord** { RaceID, DriverID, LapMs, Sector1Ms, Sector2Ms, Sector3Ms, Position, TSMs }
- **struct RaceResult** { RaceID, DriverID, FinishedAtMs, Position, TotalLaps, BestLapMs, TotalTimeMs, DNF }
- **struct Collector**
### Functions
- func NewCollector() *Collector
### Methods
- func (*Collector) Start()
- func (*Collector) OnConnect(string, string)
- func (*Collector) OnDisconnect(string, int64)
- func (*Collector) OnRaceCreated()
- func (*Collector) OnRaceStarted([]string)
- func (*Collector) OnRaceFinished(RaceResult)
- func (*Collector) OnLapRecorded(LapRecord)
- func (*Collector) OnInputProcessed()
- func (*Collector) OnSnapshotSent(uint64, uint64)
- func (*Collector) OnRTT(int64)
- func (*Collector) OnDistance(string, float64)
- func (*Collector) Server() ServerStats
- func (*Collector) Driver(string, string) DriverProfile
- func (*Collector) Drivers() []DriverProfile
- func (*Collector) RecentLaps(int) []LapRecord
- func (*Collector) RecentResults(int) []RaceResult
---
## Package `main` (`scripts/genseed`)
Package main contains the genseed utility. It is built into a standalone
binary via `go build ./scripts/genseed` and is not part of the runtime.
Usage:
go run ./scripts/genseed seeds.json > 002_seed.sql
It reads the JSON dump produced by `DUMP_SEEDS=1 go test ./internal/catalog`
and emits ON CONFLICT-safe INSERTs for the catalog tables.
---
## Package `main` (`scripts/genseed-json`)
Package main contains a small helper that prints the default seeds
(5 F1 tracks + 5 F1 cars) as JSON to stdout. It is run as:
go run ./scripts/genseed-json > seeds.json
which is then fed into scripts/genseed to regenerate the SQL.
---
## Package `main` (`scripts/graphify`)
Package main is the graphify utility. It walks the codebase, parses Go AST,
and outputs a highly compact symbol summary to server/CODEBASE.md.
This is used for token savings and helping developers/AI agents quickly map out imports and structures.
### Structs
- **struct PackageSummary** { Name, Path, Doc, Structs, Interfaces, Functions, Methods }
---
## Package `seeddefs` (`internal/catalog/seeddefs`)
Package seeddefs holds the canonical seed definitions for tracks and
cars. It lives in its own package (no postgres dependencies) so both
the catalog tests and the scripts/genseed SQL generator can consume
the same source-of-truth data.
### Structs
- **struct Waypoint** { X, Y, HeadingRad, SpeedMs, Curvature }
- **struct Bounds** { MinX, MinY, MaxX, MaxY, LengthM, WidthM }
- **struct Track** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline, Bounds, BestLapMs, BestLapHolder }
- **struct Chassis** { Model, Material, Printed, WheelbaseMm, TrackMm }
- **struct Motor** { Kind, Class, KV, PowerW }
- **struct Battery** { VoltageV, CapacityMah, Cells, Chemistry }
- **struct Car** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, TopSpeedMs, ColorHex, Active, TotalDistanceM, TotalRaces, TotalLaps, BestLapMs }
### Functions
- func DefaultTracks() []Track
- func DefaultCars() []Car
- func Monaco() Track
- func BarcelonaCatalunya() Track
- func AustriaRedBullRing() Track
- func GreatBritainSilverstone() Track
- func BelgiumSpa() Track
- func RedBullRB20() Car
- func FerrariSF24() Car
- func McLarenMCL38() Car
- func MercedesW15() Car
- func AstonMartinAMR24() Car
- func ComputeBounds([]Waypoint) Bounds
---
+118 -1
View File
@@ -24,9 +24,12 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/x0gp/server/internal/auth"
"github.com/x0gp/server/internal/clans" "github.com/x0gp/server/internal/clans"
"github.com/x0gp/server/internal/config"
"github.com/x0gp/server/internal/drivers" "github.com/x0gp/server/internal/drivers"
"github.com/x0gp/server/internal/lobby" "github.com/x0gp/server/internal/lobby"
"github.com/x0gp/server/internal/races"
"github.com/x0gp/server/internal/transport" "github.com/x0gp/server/internal/transport"
) )
@@ -284,13 +287,102 @@ func driversListHandler(svc *drivers.Service) http.HandlerFunc {
// @Router /api/drivers/{id} [put] // @Router /api/drivers/{id} [put]
// @Router /api/drivers/{id} [delete] // @Router /api/drivers/{id} [delete]
// @Router /api/drivers/{id}/car [put] // @Router /api/drivers/{id}/car [put]
func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.HandlerFunc { func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service, racesSvc *races.Service, cfg *config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/drivers/") id := strings.TrimPrefix(r.URL.Path, "/api/drivers/")
if id == "" { if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "missing driver id") writeError(w, http.StatusBadRequest, "bad_request", "missing driver id")
return return
} }
if strings.HasSuffix(id, "/profile") {
driverID := strings.TrimSuffix(id, "/profile")
if driverID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "missing driver id")
return
}
if driverID == "me" {
authedID, ok := auth.DriverIDFromContext(r.Context())
if !ok {
if cfg.DevMode {
driverID = "driver-alice"
} else {
writeError(w, http.StatusUnauthorized, "unauthorized", "missing or invalid auth token")
return
}
} else {
driverID = authedID
}
}
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method)
return
}
drv, err := svc.Get(r.Context(), driverID)
if err != nil {
if errors.Is(err, drivers.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "driver not found")
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
dbStats, dbLast, dbBest, err := racesSvc.GetDriverProfileStats(r.Context(), driverID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
var lastSummary *transport.DriverRaceSummary
if dbLast != nil {
lastSummary = &transport.DriverRaceSummary{
RaceID: dbLast.RaceID,
RaceName: dbLast.RaceName,
FinishedMs: dbLast.FinishedMs,
Position: dbLast.Position,
TotalTimeMs: dbLast.TotalTimeMs,
BestLapMs: dbLast.BestLapMs,
TrackID: dbLast.TrackID,
TrackName: dbLast.TrackName,
}
}
var bestSummary *transport.DriverRaceSummary
if dbBest != nil {
bestSummary = &transport.DriverRaceSummary{
RaceID: dbBest.RaceID,
RaceName: dbBest.RaceName,
FinishedMs: dbBest.FinishedMs,
Position: dbBest.Position,
TotalTimeMs: dbBest.TotalTimeMs,
BestLapMs: dbBest.BestLapMs,
TrackID: dbBest.TrackID,
TrackName: dbBest.TrackName,
}
}
profile := transport.DriverProfileResponse{
Driver: driverToWire(drv),
Stats: transport.DriverStats{
TotalRacesStarted: dbStats.TotalRacesStarted,
TotalRacesFinished: dbStats.TotalRacesFinished,
Wins: dbStats.Wins,
Podiums: dbStats.Podiums,
BestLapMs: dbStats.BestLapMs,
TotalPlaytimeMs: dbStats.TotalPlaytimeMs,
},
LastRace: lastSummary,
BestRace: bestSummary,
}
writeJSON(w, http.StatusOK, profile)
return
}
if strings.HasSuffix(id, "/car") { if strings.HasSuffix(id, "/car") {
driverID := strings.TrimSuffix(id, "/car") driverID := strings.TrimSuffix(id, "/car")
if driverID == "" { if driverID == "" {
@@ -389,12 +481,21 @@ func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.Hand
} }
writeJSON(w, http.StatusOK, driverToWire(d)) writeJSON(w, http.StatusOK, driverToWire(d))
case http.MethodPut: case http.MethodPut:
if !cfg.DevMode {
authedID, ok := auth.DriverIDFromContext(r.Context())
if !ok || authedID != id {
writeError(w, http.StatusForbidden, "forbidden", "cannot update another driver's profile")
return
}
}
var req transport.DriverUpdateRequest var req transport.DriverUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error()) writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return return
} }
d, err := svc.Update(r.Context(), id, drivers.UpdateInput{ d, err := svc.Update(r.Context(), id, drivers.UpdateInput{
Nickname: req.Nickname,
Name: req.Name, Name: req.Name,
AvatarURL: req.AvatarURL, AvatarURL: req.AvatarURL,
ClanID: req.ClanID, ClanID: req.ClanID,
@@ -404,11 +505,27 @@ func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.Hand
writeError(w, http.StatusNotFound, "not_found", "driver not found") writeError(w, http.StatusNotFound, "not_found", "driver not found")
return return
} }
if errors.Is(err, drivers.ErrAlreadyExists) {
writeError(w, http.StatusConflict, "conflict", fmt.Sprintf("nickname %s is already taken", req.Nickname))
return
}
if errors.Is(err, drivers.ErrInvalidInput) {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error()) writeError(w, http.StatusInternalServerError, "internal", err.Error())
return return
} }
writeJSON(w, http.StatusOK, driverToWire(d)) writeJSON(w, http.StatusOK, driverToWire(d))
case http.MethodDelete: case http.MethodDelete:
if !cfg.DevMode {
authedID, ok := auth.DriverIDFromContext(r.Context())
if !ok || authedID != id {
writeError(w, http.StatusForbidden, "forbidden", "cannot delete another driver's profile")
return
}
}
if err := svc.Delete(r.Context(), id); err != nil { if err := svc.Delete(r.Context(), id); err != nil {
if errors.Is(err, drivers.ErrNotFound) { if errors.Is(err, drivers.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "driver not found") writeError(w, http.StatusNotFound, "not_found", "driver not found")
+1 -1
View File
@@ -312,7 +312,7 @@ func main() {
apiMux.HandleFunc("/api/clans", clansRouter(clansSvc)) apiMux.HandleFunc("/api/clans", clansRouter(clansSvc))
apiMux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc)) apiMux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc))
apiMux.HandleFunc("/api/drivers", driversRouter(driversSvc)) apiMux.HandleFunc("/api/drivers", driversRouter(driversSvc))
apiMux.HandleFunc("/api/drivers/", driversByIDHandler(driversSvc, lobbySvc)) apiMux.HandleFunc("/api/drivers/", driversByIDHandler(driversSvc, lobbySvc, racesSvc, cfg))
apiMux.HandleFunc("/api/video/stream", videoSvc.StreamHandler()) apiMux.HandleFunc("/api/video/stream", videoSvc.StreamHandler())
apiMux.HandleFunc("/api/video/control", videoSvc.ControlHandler()) apiMux.HandleFunc("/api/video/control", videoSvc.ControlHandler())
apiMux.HandleFunc("/api/webrtc/connect", webrtcSvc.ConnectHandler()) apiMux.HandleFunc("/api/webrtc/connect", webrtcSvc.ConnectHandler())
+4
View File
@@ -2628,6 +2628,10 @@ const docTemplate = `{
"name": { "name": {
"type": "string", "type": "string",
"example": "Alice" "example": "Alice"
},
"nickname": {
"type": "string",
"example": "ALI"
} }
} }
}, },
+4
View File
@@ -2626,6 +2626,10 @@
"name": { "name": {
"type": "string", "type": "string",
"example": "Alice" "example": "Alice"
},
"nickname": {
"type": "string",
"example": "ALI"
} }
} }
}, },
+3
View File
@@ -245,6 +245,9 @@ definitions:
name: name:
example: Alice example: Alice
type: string type: string
nickname:
example: ALI
type: string
type: object type: object
transport.Envelope: transport.Envelope:
properties: properties:
+12
View File
@@ -78,6 +78,7 @@ func (s *Service) List(ctx context.Context, limit, offset int, clanID string) ([
// UpdateInput is the patch payload. // UpdateInput is the patch payload.
type UpdateInput struct { type UpdateInput struct {
Nickname string
Name string Name string
AvatarURL string AvatarURL string
ClanID *string // nil = leave unchanged, &"" = clear, &"..." = set ClanID *string // nil = leave unchanged, &"" = clear, &"..." = set
@@ -89,6 +90,13 @@ func (s *Service) Update(ctx context.Context, id string, in UpdateInput) (Driver
if err != nil { if err != nil {
return Driver{}, err return Driver{}, err
} }
if in.Nickname != "" {
nick := strings.ToUpper(strings.TrimSpace(in.Nickname))
if !IsValidNickname(nick) {
return Driver{}, fmt.Errorf("%w: nickname must be 3 uppercase ASCII letters", ErrInvalidInput)
}
cur.Nickname = nick
}
if in.Name != "" { if in.Name != "" {
cur.Name = in.Name cur.Name = in.Name
} }
@@ -99,6 +107,10 @@ func (s *Service) Update(ctx context.Context, id string, in UpdateInput) (Driver
cur.ClanID = *in.ClanID cur.ClanID = *in.ClanID
} }
if err := s.pg.Update(ctx, cur); err != nil { if err := s.pg.Update(ctx, cur); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return Driver{}, fmt.Errorf("%w: %s", ErrAlreadyExists, cur.Nickname)
}
return Driver{}, err return Driver{}, err
} }
return s.pg.Get(ctx, id) return s.pg.Get(ctx, id)
+30 -2
View File
@@ -70,6 +70,11 @@ func (m *memoryStore) Update(ctx context.Context, d Driver) error {
if _, ok := m.drivers[d.ID]; !ok { if _, ok := m.drivers[d.ID]; !ok {
return ErrNotFound return ErrNotFound
} }
for id, existing := range m.drivers {
if id != d.ID && existing.Nickname == d.Nickname {
return &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint"}
}
}
m.drivers[d.ID] = d m.drivers[d.ID] = d
return nil return nil
} }
@@ -141,9 +146,10 @@ func TestDriversService(t *testing.T) {
t.Errorf("Expected driver-1, got %s", gotByNick.ID) t.Errorf("Expected driver-1, got %s", gotByNick.ID)
} }
// 7. Update driver // 7. Update driver (including nickname)
clanID := "clan-2" clanID := "clan-2"
updated, err := svc.Update(ctx, "driver-1", UpdateInput{ updated, err := svc.Update(ctx, "driver-1", UpdateInput{
Nickname: "VET",
Name: "Lewis Hamilton MBE", Name: "Lewis Hamilton MBE",
AvatarURL: "https://example.com/ham-mbe.png", AvatarURL: "https://example.com/ham-mbe.png",
ClanID: &clanID, ClanID: &clanID,
@@ -151,10 +157,32 @@ func TestDriversService(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Update failed: %v", err) t.Fatalf("Update failed: %v", err)
} }
if updated.Name != "Lewis Hamilton MBE" || updated.AvatarURL != "https://example.com/ham-mbe.png" || updated.ClanID != "clan-2" { if updated.Nickname != "VET" || updated.Name != "Lewis Hamilton MBE" || updated.AvatarURL != "https://example.com/ham-mbe.png" || updated.ClanID != "clan-2" {
t.Errorf("Unexpected updated driver fields: %+v", updated) t.Errorf("Unexpected updated driver fields: %+v", updated)
} }
// 7a. Update with invalid nickname
_, err = svc.Update(ctx, "driver-1", UpdateInput{Nickname: "TOOLONG"})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("Expected ErrInvalidInput for invalid nickname update, got: %v", err)
}
// 7b. Create driver-2 to test duplicate nickname conflict
_, err = svc.Create(ctx, CreateInput{
ID: "driver-2",
Nickname: "HAM",
Name: "Lewis",
})
if err != nil {
t.Fatalf("Create driver-2 failed: %v", err)
}
// 7c. Update driver-2 to nickname "VET" (should fail, already taken by driver-1)
_, err = svc.Update(ctx, "driver-2", UpdateInput{Nickname: "VET"})
if !errors.Is(err, ErrAlreadyExists) {
t.Errorf("Expected ErrAlreadyExists for duplicate nickname update, got: %v", err)
}
// 8. Delete driver // 8. Delete driver
err = svc.Delete(ctx, "driver-1") err = svc.Delete(ctx, "driver-1")
if err != nil { if err != nil {
+2 -2
View File
@@ -165,9 +165,9 @@ func (s *PgStore) Update(ctx context.Context, d Driver) error {
d.UpdatedMs = time.Now().UnixMilli() d.UpdatedMs = time.Now().UnixMilli()
tag, err := s.pool.Exec(ctx, ` tag, err := s.pool.Exec(ctx, `
UPDATE drivers UPDATE drivers
SET name = $2, avatar_url = $3, clan_id = $4, updated_ms = $5 SET nickname = $2, name = $3, avatar_url = $4, clan_id = $5, updated_ms = $6
WHERE id = $1`, WHERE id = $1`,
d.ID, d.Name, d.AvatarURL, nullableClan(d.ClanID), d.UpdatedMs) d.ID, d.Nickname, d.Name, d.AvatarURL, nullableClan(d.ClanID), d.UpdatedMs)
if err != nil { if err != nil {
return err return err
} }
+124
View File
@@ -393,3 +393,127 @@ func TestGetRaceAndLastFinished(t *testing.T) {
} }
} }
func TestDriverProfileStats(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL is empty; skipping integration test")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("failed to open pool: %v", err)
}
defer pool.Close()
store := NewPgStore(pool)
// Clean up any test records
_, _ = store.Exec(ctx, `DELETE FROM race_queue WHERE driver_id = 'test-stats-drv'`)
_, _ = store.Exec(ctx, `DELETE FROM race_drivers WHERE driver_id = 'test-stats-drv'`)
_, _ = store.Exec(ctx, `DELETE FROM races WHERE id LIKE 'test-stats-race-%'`)
_, _ = store.Exec(ctx, `DELETE FROM drivers WHERE id = 'test-stats-drv'`)
// 1. Create a driver
_, _ = store.Exec(ctx, `
INSERT INTO drivers (id, nickname, name, avatar_url, created_ms, updated_ms)
VALUES ('test-stats-drv', 'TSD', 'Test Stats Driver', '', 1700000000, 1700000000)`)
// 2. Insert races and race results
// Race 1: finished, TSD took 2nd position, best lap 15000, total time 50000
race1 := FinishedRace{
ID: "test-stats-race-1",
Name: "Race 1",
TrackID: "monaco",
MaxCars: 4,
Laps: 5,
TimeLimitS: 300,
DriverIDs: []string{"test-stats-drv"},
Status: "finished",
CreatedMs: 1700000000,
StartedMs: 1700000500,
FinishedMs: 1700060000,
DurationMs: 59500,
TotalLaps: 5,
TotalDrivers: 2,
Results: []DriverResult{
{
DriverID: "test-stats-drv",
TotalTimeMs: 50000,
BestLapMs: 15000,
Position: ptrInt(2),
},
},
}
if err := store.InsertFinished(ctx, race1); err != nil {
t.Fatalf("InsertFinished 1 failed: %v", err)
}
// Race 2: finished, TSD took 1st position (Win), best lap 14000 (Best Lap), total time 45000
race2 := FinishedRace{
ID: "test-stats-race-2",
Name: "Race 2",
TrackID: "monaco",
MaxCars: 4,
Laps: 5,
TimeLimitS: 300,
DriverIDs: []string{"test-stats-drv"},
Status: "finished",
CreatedMs: 1700100000,
StartedMs: 1700100500,
FinishedMs: 1700160000,
DurationMs: 59500,
TotalLaps: 5,
TotalDrivers: 2,
Results: []DriverResult{
{
DriverID: "test-stats-drv",
TotalTimeMs: 45000,
BestLapMs: 14000,
Position: ptrInt(1),
},
},
}
if err := store.InsertFinished(ctx, race2); err != nil {
t.Fatalf("InsertFinished 2 failed: %v", err)
}
// 3. Query stats
stats, err := store.GetDriverStats(ctx, "test-stats-drv")
if err != nil {
t.Fatalf("GetDriverStats failed: %v", err)
}
if stats.TotalRacesStarted != 2 || stats.TotalRacesFinished != 2 {
t.Errorf("expected 2 races started/finished, got: %+v", stats)
}
if stats.Wins != 1 || stats.Podiums != 2 {
t.Errorf("expected 1 win, 2 podiums, got: %+v", stats)
}
if stats.BestLapMs != 14000 {
t.Errorf("expected best lap 14000, got %d", stats.BestLapMs)
}
if stats.TotalPlaytimeMs != 95000 {
t.Errorf("expected total playtime 95000, got %d", stats.TotalPlaytimeMs)
}
// 4. Query last race (should be Race 2, finished at 1700160000)
last, err := store.GetDriverLastRace(ctx, "test-stats-drv")
if err != nil {
t.Fatalf("GetDriverLastRace failed: %v", err)
}
if last == nil || last.RaceID != "test-stats-race-2" {
t.Errorf("expected last race test-stats-race-2, got: %+v", last)
}
// 5. Query best race (should be Race 2, because position 1 is better than 2)
best, err := store.GetDriverBestRace(ctx, "test-stats-drv")
if err != nil {
t.Fatalf("GetDriverBestRace failed: %v", err)
}
if best == nil || best.RaceID != "test-stats-race-2" {
t.Errorf("expected best race test-stats-race-2, got: %+v", best)
}
}
func ptrInt(v int) *int { return &v }
+17
View File
@@ -837,6 +837,23 @@ func (s *Service) GetLastFinished(ctx context.Context) (FinishedRace, error) {
return s.pg.GetLastFinished(ctx) return s.pg.GetLastFinished(ctx)
} }
// GetDriverProfileStats returns the driver stats and summaries of their last and best races.
func (s *Service) GetDriverProfileStats(ctx context.Context, driverID string) (DriverStats, *DriverRaceSummary, *DriverRaceSummary, error) {
stats, err := s.pg.GetDriverStats(ctx, driverID)
if err != nil {
return DriverStats{}, nil, nil, err
}
last, err := s.pg.GetDriverLastRace(ctx, driverID)
if err != nil {
return DriverStats{}, nil, nil, err
}
best, err := s.pg.GetDriverBestRace(ctx, driverID)
if err != nil {
return DriverStats{}, nil, nil, err
}
return stats, last, best, nil
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Scheduler // Scheduler
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+129
View File
@@ -1046,3 +1046,132 @@ func (f FinishedRace) ToLobbyMeta() lobby.RaceMeta {
StartedMs: f.StartedMs, StartedMs: f.StartedMs,
} }
} }
// DriverStats holds database-calculated stats.
type DriverStats struct {
TotalRacesStarted int64
TotalRacesFinished int64
Wins int64
Podiums int64
BestLapMs int64
TotalPlaytimeMs int64
}
// DriverRaceSummary holds a summary of a single finished race.
type DriverRaceSummary struct {
RaceID string
RaceName string
FinishedMs int64
Position int
TotalTimeMs int64
BestLapMs int64
TrackID string
TrackName string
}
// GetDriverStats aggregates statistics for a given driver from finished races.
func (s *PgStore) GetDriverStats(ctx context.Context, driverID string) (DriverStats, error) {
row := s.pool.QueryRow(ctx, `
SELECT
COUNT(*)::bigint as total_races_started,
COUNT(CASE WHEN rd.position IS NOT NULL OR rd.total_time_ms > 0 THEN 1 END)::bigint as total_races_finished,
COUNT(CASE WHEN rd.position = 1 THEN 1 END)::bigint as wins,
COUNT(CASE WHEN rd.position >= 1 AND rd.position <= 3 THEN 1 END)::bigint as podiums,
COALESCE(MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms END), 0)::bigint as best_lap_ms,
COALESCE(SUM(rd.total_time_ms), 0)::bigint as total_playtime_ms
FROM race_drivers rd
JOIN races r ON rd.race_id = r.id
WHERE rd.driver_id = $1 AND r.status = 'finished'`, driverID)
var stats DriverStats
err := row.Scan(
&stats.TotalRacesStarted,
&stats.TotalRacesFinished,
&stats.Wins,
&stats.Podiums,
&stats.BestLapMs,
&stats.TotalPlaytimeMs,
)
if err != nil {
return DriverStats{}, fmt.Errorf("query driver stats: %w", err)
}
return stats, nil
}
// GetDriverLastRace returns the last completed race for a driver.
func (s *PgStore) GetDriverLastRace(ctx context.Context, driverID string) (*DriverRaceSummary, error) {
row := s.pool.QueryRow(ctx, `
SELECT
rd.race_id,
r.name,
r.finished_ms,
COALESCE(rd.position, 0) as position,
rd.total_time_ms,
rd.best_lap_ms,
r.track_id,
COALESCE(t.name, '') as track_name
FROM race_drivers rd
JOIN races r ON rd.race_id = r.id
LEFT JOIN tracks t ON r.track_id = t.id
WHERE rd.driver_id = $1 AND r.status = 'finished'
ORDER BY r.finished_ms DESC, r.id DESC
LIMIT 1`, driverID)
var sum DriverRaceSummary
err := row.Scan(
&sum.RaceID,
&sum.RaceName,
&sum.FinishedMs,
&sum.Position,
&sum.TotalTimeMs,
&sum.BestLapMs,
&sum.TrackID,
&sum.TrackName,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("query driver last race: %w", err)
}
return &sum, nil
}
// GetDriverBestRace returns the best completed race for a driver (by position, then time).
func (s *PgStore) GetDriverBestRace(ctx context.Context, driverID string) (*DriverRaceSummary, error) {
row := s.pool.QueryRow(ctx, `
SELECT
rd.race_id,
r.name,
r.finished_ms,
COALESCE(rd.position, 0) as position,
rd.total_time_ms,
rd.best_lap_ms,
r.track_id,
COALESCE(t.name, '') as track_name
FROM race_drivers rd
JOIN races r ON rd.race_id = r.id
LEFT JOIN tracks t ON r.track_id = t.id
WHERE rd.driver_id = $1 AND r.status = 'finished' AND rd.position IS NOT NULL AND rd.position > 0
ORDER BY rd.position ASC, rd.total_time_ms ASC, r.finished_ms DESC, r.id DESC
LIMIT 1`, driverID)
var sum DriverRaceSummary
err := row.Scan(
&sum.RaceID,
&sum.RaceName,
&sum.FinishedMs,
&sum.Position,
&sum.TotalTimeMs,
&sum.BestLapMs,
&sum.TrackID,
&sum.TrackName,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("query driver best race: %w", err)
}
return &sum, nil
}
+33 -2
View File
@@ -674,11 +674,42 @@ type DriverCreateRequest struct {
// DriverUpdateRequest is the body of PUT /api/drivers/{id}. // DriverUpdateRequest is the body of PUT /api/drivers/{id}.
type DriverUpdateRequest struct { type DriverUpdateRequest struct {
Name string `json:"name,omitempty" example:"Alice"` Nickname string `json:"nickname,omitempty" example:"ALI"`
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/u/ace.png"` Name string `json:"name,omitempty" example:"Alice"`
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/u/ace.png"`
ClanID *string `json:"clan_id,omitempty" example:"clan-1782000-1"` ClanID *string `json:"clan_id,omitempty" example:"clan-1782000-1"`
} }
// DriverProfileResponse is the body returned by GET /api/drivers/{id}/profile.
type DriverProfileResponse struct {
Driver Driver `json:"driver"`
Stats DriverStats `json:"stats"`
LastRace *DriverRaceSummary `json:"last_race"`
BestRace *DriverRaceSummary `json:"best_race"`
}
// DriverStats contains aggregate driver statistics.
type DriverStats struct {
TotalRacesStarted int64 `json:"total_races_started" example:"10"`
TotalRacesFinished int64 `json:"total_races_finished" example:"8"`
Wins int64 `json:"wins" example:"2"`
Podiums int64 `json:"podiums" example:"5"`
BestLapMs int64 `json:"best_lap_ms" example:"12345"`
TotalPlaytimeMs int64 `json:"total_playtime_ms" example:"1200000"`
}
// DriverRaceSummary describes one race result in a driver's profile.
type DriverRaceSummary struct {
RaceID string `json:"race_id" example:"race-123"`
RaceName string `json:"race_name" example:"Monaco Grand Prix"`
FinishedMs int64 `json:"finished_ms" example:"1700000000000"`
Position int `json:"position" example:"1"`
TotalTimeMs int64 `json:"total_time_ms" example:"123456"`
BestLapMs int64 `json:"best_lap_ms" example:"12345"`
TrackID string `json:"track_id" example:"monaco"`
TrackName string `json:"track_name" example:"Monaco"`
}
// DriverListResponse is the body returned by GET /api/drivers. // DriverListResponse is the body returned by GET /api/drivers.
type DriverListResponse struct { type DriverListResponse struct {
Items []Driver `json:"items"` Items []Driver `json:"items"`