mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-18 13:27:03 +00:00
Add driver profile stats API, nickname update editing, and security checks
This commit is contained in:
@@ -82,7 +82,8 @@
|
||||
- CRUD:
|
||||
- `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}`
|
||||
- `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`).
|
||||
- Podium в `LobbyRace` заполняется только для finished-гонок (top-3: position, driver_id, name, total_time_ms).
|
||||
|
||||
|
||||
+403
-394
@@ -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.
|
||||
|
||||
## 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 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 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 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 QueueEntry** { DriverID, RaceID, PlanID, EnqueuedMs }
|
||||
- **struct PgStore**
|
||||
- **struct DriverStats** { TotalRacesStarted, TotalRacesFinished, Wins, Podiums, BestLapMs, TotalPlaytimeMs }
|
||||
- **struct DriverRaceSummary** { RaceID, RaceName, FinishedMs, Position, TotalTimeMs, BestLapMs, TrackID, TrackName }
|
||||
|
||||
### Functions
|
||||
- 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) GetRace(context.Context, string) (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) Stop()
|
||||
- func (*Scheduler) Done() ?
|
||||
@@ -315,6 +270,355 @@ The package composes two storage backends:
|
||||
- func (*PgStore) ListQueueByRace(context.Context, string) ([]QueueEntry, error)
|
||||
- func (*PgStore) CountQueueByRace(context.Context, string) (int, error)
|
||||
- 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 Driver** { ID, Nickname, Name, AvatarURL, ClanID, CreatedMs, UpdatedMs }
|
||||
- **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 Clan** { ID, Tag, Name, AvatarURL, CreatedMs, UpdatedMs }
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,9 +24,12 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/x0gp/server/internal/auth"
|
||||
"github.com/x0gp/server/internal/clans"
|
||||
"github.com/x0gp/server/internal/config"
|
||||
"github.com/x0gp/server/internal/drivers"
|
||||
"github.com/x0gp/server/internal/lobby"
|
||||
"github.com/x0gp/server/internal/races"
|
||||
"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} [delete]
|
||||
// @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) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/drivers/")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing driver id")
|
||||
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") {
|
||||
driverID := strings.TrimSuffix(id, "/car")
|
||||
if driverID == "" {
|
||||
@@ -389,12 +481,21 @@ func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.Hand
|
||||
}
|
||||
writeJSON(w, http.StatusOK, driverToWire(d))
|
||||
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
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
d, err := svc.Update(r.Context(), id, drivers.UpdateInput{
|
||||
Nickname: req.Nickname,
|
||||
Name: req.Name,
|
||||
AvatarURL: req.AvatarURL,
|
||||
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")
|
||||
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())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, driverToWire(d))
|
||||
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 errors.Is(err, drivers.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "driver not found")
|
||||
|
||||
@@ -312,7 +312,7 @@ func main() {
|
||||
apiMux.HandleFunc("/api/clans", clansRouter(clansSvc))
|
||||
apiMux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc))
|
||||
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/control", videoSvc.ControlHandler())
|
||||
apiMux.HandleFunc("/api/webrtc/connect", webrtcSvc.ConnectHandler())
|
||||
|
||||
@@ -2628,6 +2628,10 @@ const docTemplate = `{
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "Alice"
|
||||
},
|
||||
"nickname": {
|
||||
"type": "string",
|
||||
"example": "ALI"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2626,6 +2626,10 @@
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "Alice"
|
||||
},
|
||||
"nickname": {
|
||||
"type": "string",
|
||||
"example": "ALI"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -245,6 +245,9 @@ definitions:
|
||||
name:
|
||||
example: Alice
|
||||
type: string
|
||||
nickname:
|
||||
example: ALI
|
||||
type: string
|
||||
type: object
|
||||
transport.Envelope:
|
||||
properties:
|
||||
|
||||
@@ -78,6 +78,7 @@ func (s *Service) List(ctx context.Context, limit, offset int, clanID string) ([
|
||||
|
||||
// UpdateInput is the patch payload.
|
||||
type UpdateInput struct {
|
||||
Nickname string
|
||||
Name string
|
||||
AvatarURL string
|
||||
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 {
|
||||
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 != "" {
|
||||
cur.Name = in.Name
|
||||
}
|
||||
@@ -99,6 +107,10 @@ func (s *Service) Update(ctx context.Context, id string, in UpdateInput) (Driver
|
||||
cur.ClanID = *in.ClanID
|
||||
}
|
||||
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 s.pg.Get(ctx, id)
|
||||
|
||||
@@ -70,6 +70,11 @@ func (m *memoryStore) Update(ctx context.Context, d Driver) error {
|
||||
if _, ok := m.drivers[d.ID]; !ok {
|
||||
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
|
||||
return nil
|
||||
}
|
||||
@@ -141,9 +146,10 @@ func TestDriversService(t *testing.T) {
|
||||
t.Errorf("Expected driver-1, got %s", gotByNick.ID)
|
||||
}
|
||||
|
||||
// 7. Update driver
|
||||
// 7. Update driver (including nickname)
|
||||
clanID := "clan-2"
|
||||
updated, err := svc.Update(ctx, "driver-1", UpdateInput{
|
||||
Nickname: "VET",
|
||||
Name: "Lewis Hamilton MBE",
|
||||
AvatarURL: "https://example.com/ham-mbe.png",
|
||||
ClanID: &clanID,
|
||||
@@ -151,10 +157,32 @@ func TestDriversService(t *testing.T) {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
err = svc.Delete(ctx, "driver-1")
|
||||
if err != nil {
|
||||
|
||||
@@ -165,9 +165,9 @@ func (s *PgStore) Update(ctx context.Context, d Driver) error {
|
||||
d.UpdatedMs = time.Now().UnixMilli()
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
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`,
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -837,6 +837,23 @@ func (s *Service) GetLastFinished(ctx context.Context) (FinishedRace, error) {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1046,3 +1046,132 @@ func (f FinishedRace) ToLobbyMeta() lobby.RaceMeta {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -674,11 +674,42 @@ type DriverCreateRequest struct {
|
||||
|
||||
// DriverUpdateRequest is the body of PUT /api/drivers/{id}.
|
||||
type DriverUpdateRequest struct {
|
||||
Nickname string `json:"nickname,omitempty" example:"ALI"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
type DriverListResponse struct {
|
||||
Items []Driver `json:"items"`
|
||||
|
||||
Reference in New Issue
Block a user