diff --git a/AGENTS.md b/AGENTS.md index 2237c8c..78506e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/server/CODEBASE.md b/server/CODEBASE.md index 4d742fb..5bbaae0 100644 --- a/server/CODEBASE.md +++ b/server/CODEBASE.md @@ -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 - ---- - diff --git a/server/cmd/poc-server/drivers_clans_handlers.go b/server/cmd/poc-server/drivers_clans_handlers.go index eab6c16..1cb31de 100644 --- a/server/cmd/poc-server/drivers_clans_handlers.go +++ b/server/cmd/poc-server/drivers_clans_handlers.go @@ -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") diff --git a/server/cmd/poc-server/main.go b/server/cmd/poc-server/main.go index f2693f3..75d4b8f 100644 --- a/server/cmd/poc-server/main.go +++ b/server/cmd/poc-server/main.go @@ -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()) diff --git a/server/docs/docs.go b/server/docs/docs.go index c1b911d..16ce859 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -2628,6 +2628,10 @@ const docTemplate = `{ "name": { "type": "string", "example": "Alice" + }, + "nickname": { + "type": "string", + "example": "ALI" } } }, diff --git a/server/docs/swagger.json b/server/docs/swagger.json index 026b441..2cdec00 100644 --- a/server/docs/swagger.json +++ b/server/docs/swagger.json @@ -2626,6 +2626,10 @@ "name": { "type": "string", "example": "Alice" + }, + "nickname": { + "type": "string", + "example": "ALI" } } }, diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index da6383b..412ef59 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -245,6 +245,9 @@ definitions: name: example: Alice type: string + nickname: + example: ALI + type: string type: object transport.Envelope: properties: diff --git a/server/internal/drivers/service.go b/server/internal/drivers/service.go index bfb4097..6e81e05 100644 --- a/server/internal/drivers/service.go +++ b/server/internal/drivers/service.go @@ -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) diff --git a/server/internal/drivers/service_test.go b/server/internal/drivers/service_test.go index f07c9c7..c2048d1 100644 --- a/server/internal/drivers/service_test.go +++ b/server/internal/drivers/service_test.go @@ -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 { diff --git a/server/internal/drivers/store.go b/server/internal/drivers/store.go index f5efd13..c4636d7 100644 --- a/server/internal/drivers/store.go +++ b/server/internal/drivers/store.go @@ -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 } diff --git a/server/internal/races/leaderboard_test.go b/server/internal/races/leaderboard_test.go index 779cc61..951402e 100644 --- a/server/internal/races/leaderboard_test.go +++ b/server/internal/races/leaderboard_test.go @@ -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 } + diff --git a/server/internal/races/service.go b/server/internal/races/service.go index f6af10e..0a914e0 100644 --- a/server/internal/races/service.go +++ b/server/internal/races/service.go @@ -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 // --------------------------------------------------------------------------- diff --git a/server/internal/races/store.go b/server/internal/races/store.go index 770fd80..fd911c8 100644 --- a/server/internal/races/store.go +++ b/server/internal/races/store.go @@ -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 +} diff --git a/server/internal/transport/codec.go b/server/internal/transport/codec.go index 4dae091..408b432 100644 --- a/server/internal/transport/codec.go +++ b/server/internal/transport/codec.go @@ -674,11 +674,42 @@ type DriverCreateRequest struct { // DriverUpdateRequest is the body of PUT /api/drivers/{id}. type DriverUpdateRequest struct { - Name string `json:"name,omitempty" example:"Alice"` - AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/u/ace.png"` + 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"`