Add races results GET endpoints (by ID and last finished) and regenerate swagger specs

This commit is contained in:
2026-07-17 22:41:55 +04:00
parent c8ff4c04ad
commit 9c9632343d
10 changed files with 812 additions and 318 deletions
+2
View File
@@ -67,6 +67,8 @@
### API гонок (keyset-пагинация)
- `GET /api/races` — список (live + finished), фильтры `status,track_id`, `cursor`, `limit` (1..200, default 50). Status enum: `all|lobby|racing|finished`. **Кастомных лобби нет** — гонки привязаны к физическим трекам, фильтр только по `track_id`. Cursor — base64(`ms:id[:src]`).
- `GET /api/races/last` — результаты (podium/details) последнего законченного заезда.
- `GET /api/races/{id}` — результаты (podium/details) или статус конкретного заезда по ID (работает для активных и завершенных гонок).
- `GET /api/races/upcoming?limit=3` — ближайшие lobby|countdown.
- `POST /api/races/queue/join` — встать в очередь на следующие N (body: `driver_id,limit` или header `X-Driver-Id`).
- `GET /api/races/queue?driver_id=...` — посмотреть.
+322 -318
View File
@@ -2,95 +2,6 @@
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 `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, UDPVideoAddr, ESPCmdPort }
### Functions
- func Load() (*Config, error)
---
## Package `postgres` (`internal/storage/postgres`)
Package postgres owns the persistence layer of the catalog.
It exposes a *pgxpool.Pool plus a small set of repository helpers used by
internal/catalog. Migrations are applied at startup from the SQL files in
the migrations/ subdirectory.
### Structs
- **struct PgStore**
- **struct Config** { URL, Host, Port, User, Password, Database, SSLMode, MaxConns, MinConns, ConnectTimeout }
### Functions
- func NewPgStore(*pgxpool.Pool) *PgStore
- func Open(context.Context, Config) (*pgxpool.Pool, error)
- func Migrate(context.Context, *pgxpool.Pool) error
### Methods
- func (*PgStore) Load(context.Context) ([]catalog.TrackMeta, []catalog.CarMeta, error)
- func (*PgStore) UpsertTrack(context.Context, catalog.TrackMeta) error
- func (*PgStore) DeleteTrack(context.Context, string) error
- func (*PgStore) UpsertCar(context.Context, catalog.CarMeta) error
- func (*PgStore) DeleteCar(context.Context, string) error
- func (*PgStore) UpdateCarStats(context.Context, string, func) error
- func (*PgStore) GetTrackCalendar(context.Context) ([]catalog.TrackCalendarEntry, error)
- func (*PgStore) CreateTrackCalendar(context.Context, catalog.TrackCalendarEntry) (catalog.TrackCalendarEntry, error)
- func (*PgStore) DeleteTrackCalendar(context.Context, int) error
- func (Config) DSN() (string, 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 `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
@@ -172,39 +83,6 @@ Cars are sized to a 1/27 touring envelope (~80 × 35 × 25 mm).
---
## 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 `clans` (`internal/clans`)
Package clans owns persistence and business logic for clans.
@@ -246,6 +124,83 @@ 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, UDPVideoAddr, ESPCmdPort }
### Functions
- func Load() (*Config, error)
---
## Package `postgres` (`internal/storage/postgres`)
Package postgres owns the persistence layer of the catalog.
It exposes a *pgxpool.Pool plus a small set of repository helpers used by
internal/catalog. Migrations are applied at startup from the SQL files in
the migrations/ subdirectory.
### Structs
- **struct PgStore**
- **struct Config** { URL, Host, Port, User, Password, Database, SSLMode, MaxConns, MinConns, ConnectTimeout }
### Functions
- func NewPgStore(*pgxpool.Pool) *PgStore
- func Open(context.Context, Config) (*pgxpool.Pool, error)
- func Migrate(context.Context, *pgxpool.Pool) error
### Methods
- func (*PgStore) Load(context.Context) ([]catalog.TrackMeta, []catalog.CarMeta, error)
- func (*PgStore) UpsertTrack(context.Context, catalog.TrackMeta) error
- func (*PgStore) DeleteTrack(context.Context, string) error
- func (*PgStore) UpsertCar(context.Context, catalog.CarMeta) error
- func (*PgStore) DeleteCar(context.Context, string) error
- func (*PgStore) UpdateCarStats(context.Context, string, func) error
- func (*PgStore) GetTrackCalendar(context.Context) ([]catalog.TrackCalendarEntry, error)
- func (*PgStore) CreateTrackCalendar(context.Context, catalog.TrackCalendarEntry) (catalog.TrackCalendarEntry, error)
- func (*PgStore) DeleteTrackCalendar(context.Context, int) error
- func (Config) DSN() (string, error)
---
## 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 `main` (`.`)
Smoke test: ws client connects, exchanges a few messages, exits.
Run: go run ./smoke_test.go
---
## Package `control` (`internal/control`)
Package control owns the authoritative race state.
@@ -277,145 +232,6 @@ added in MVP / production phases.
---
## 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 `lobby` (`internal/lobby`)
Package lobby owns the "outside any race" state of the server: which
races are open, which drivers are connected but idle, who is the host
of each race.
The lobby is the single source of truth for matchmaking and for
rendering the "what races are available right now" list. It does NOT
own per-race physics — that's the Engine (internal/control).
All operations are thread-safe. Mutations broadcast a snapshot event
to subscribers (see Service.Subscribe).
### Interfaces
- **interface Persistence** { UpsertRace, DeleteRace, AddDriver, RemoveDriver, SetRaceStatus, UpsertDriver, DeleteDriver }
### Structs
- **struct CreateRaceOptions** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS }
- **struct RaceMeta** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, DriverIDs, Status, CreatedMs, StartedMs }
- **struct DriverMeta** { ID, Name, Nickname, AvatarURL, ClanID, ClanTag, Status, RaceID, ConnectedMs, LastSeenMs, Country, AvatarHue, CarID }
- **struct Snapshot** { GeneratedMs, Races, Drivers, Version }
- **struct Stats** { RacesTotal, RacesOpen, RacesActive, DriversIdle, DriversTotal }
- **struct Service**
### Functions
- func NewService() *Service
### Methods
- func (*Service) CreateRace(CreateRaceOptions) (RaceMeta, error)
- func (*Service) AddRace(RaceMeta)
- func (*Service) AddDriver(string, string, string) (DriverMeta, error)
- func (*Service) RemoveDriver(string)
- func (*Service) Heartbeat(string)
- func (*Service) SetDriverProfile(string, string, string, string, string)
- func (*Service) AddDriverToRace(string, string) error
- func (*Service) RemoveDriverFromRace(string)
- func (*Service) SetRaceStatus(string, RaceStatus) error
- func (*Service) DeleteRace(string) error
- func (*Service) QuickPlay(string, int) (RaceMeta, error)
- func (*Service) GetRace(string) (RaceMeta, error)
- func (*Service) GetDriver(string) (DriverMeta, error)
- func (*Service) SelectCar(string, *string) error
- func (*Service) ListRaces() []RaceMeta
- func (*Service) ListDrivers() []DriverMeta
- func (*Service) SetPersistence(Persistence)
- func (*Service) SetRaceLifecycleHook(func, func)
- func (*Service) Subscribe() (?, func)
- func (*Service) Stop()
- func (*Service) Done() ?
- func (*Service) Snapshot() Snapshot
- func (*Service) Stats() Stats
---
## 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 `races` (`internal/races`)
Package races owns the cross-cutting "race list" surface: keyset-paginated
@@ -501,6 +317,8 @@ The package composes two storage backends:
- func (*Service) DeletePlan(context.Context, string) error
- func (*Service) GetLeaderboard(context.Context, string, int, int, int, string) (*LeaderboardResult, error)
- 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 (*Scheduler) Run(context.Context)
- func (*Scheduler) Stop()
- func (*Scheduler) Done() ?
@@ -521,6 +339,8 @@ The package composes two storage backends:
- func (*PgStore) InsertFinished(context.Context, FinishedRace) error
- func (*PgStore) ListFinished(context.Context, string, Cursor, int) ([]FinishedRace, error)
- func (*PgStore) CountRaces(context.Context, []string, string) (int, error)
- func (*PgStore) GetFinished(context.Context, string) (FinishedRace, error)
- func (*PgStore) GetLastFinished(context.Context) (FinishedRace, error)
- func (*PgStore) CreatePlan(context.Context, RacePlan) error
- func (*PgStore) GetPlan(context.Context, string) (RacePlan, error)
- func (*PgStore) ListPlans(context.Context, Cursor, int) ([]RacePlan, error)
@@ -538,21 +358,151 @@ The package composes two storage backends:
---
## 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.
## Package `seed` (`internal/races/seed`)
### Structs
- **struct PackageSummary** { Name, Path, Doc, Structs, Interfaces, Functions, Methods }
- **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 `main` (`.`)
## Package `stats` (`internal/stats`)
Smoke test: ws client connects, exchanges a few messages, exits.
Run: go run ./smoke_test.go
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` (`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 `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)
---
@@ -662,50 +612,115 @@ TODO PoC: replace JSON with protobuf-generated structs (see
---
## Package `main` (`cmd/poc-server`)
## Package `seeddefs` (`internal/catalog/seeddefs`)
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.
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 Hub**
- **struct UDPVideoService**
- **struct WebRTCService**
- **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 NewHub(*slog.Logger) *Hub
- func NewUDPVideoService(*slog.Logger, string, int) *UDPVideoService
- func NewWebRTCService(*slog.Logger, *control.Engine, *lobby.Service, *UDPVideoService, *catalog.Service) *WebRTCService
- 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 `lobby` (`internal/lobby`)
Package lobby owns the "outside any race" state of the server: which
races are open, which drivers are connected but idle, who is the host
of each race.
The lobby is the single source of truth for matchmaking and for
rendering the "what races are available right now" list. It does NOT
own per-race physics — that's the Engine (internal/control).
All operations are thread-safe. Mutations broadcast a snapshot event
to subscribers (see Service.Subscribe).
### Interfaces
- **interface Persistence** { UpsertRace, DeleteRace, AddDriver, RemoveDriver, SetRaceStatus, UpsertDriver, DeleteDriver }
### Structs
- **struct CreateRaceOptions** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS }
- **struct RaceMeta** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, DriverIDs, Status, CreatedMs, StartedMs }
- **struct DriverMeta** { ID, Name, Nickname, AvatarURL, ClanID, ClanTag, Status, RaceID, ConnectedMs, LastSeenMs, Country, AvatarHue, CarID }
- **struct Snapshot** { GeneratedMs, Races, Drivers, Version }
- **struct Stats** { RacesTotal, RacesOpen, RacesActive, DriversIdle, DriversTotal }
- **struct Service**
### Functions
- func NewService() *Service
### 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()
- func (*Service) CreateRace(CreateRaceOptions) (RaceMeta, error)
- func (*Service) AddRace(RaceMeta)
- func (*Service) AddDriver(string, string, string) (DriverMeta, error)
- func (*Service) RemoveDriver(string)
- func (*Service) Heartbeat(string)
- func (*Service) SetDriverProfile(string, string, string, string, string)
- func (*Service) AddDriverToRace(string, string) error
- func (*Service) RemoveDriverFromRace(string)
- func (*Service) SetRaceStatus(string, RaceStatus) error
- func (*Service) DeleteRace(string) error
- func (*Service) QuickPlay(string, int) (RaceMeta, error)
- func (*Service) GetRace(string) (RaceMeta, error)
- func (*Service) GetDriver(string) (DriverMeta, error)
- func (*Service) SelectCar(string, *string) error
- func (*Service) ListRaces() []RaceMeta
- func (*Service) ListDrivers() []DriverMeta
- func (*Service) SetPersistence(Persistence)
- func (*Service) SetRaceLifecycleHook(func, func)
- func (*Service) Subscribe() (?, func)
- func (*Service) Stop()
- func (*Service) Done() ?
- func (*Service) Snapshot() Snapshot
- func (*Service) Stats() Stats
---
## 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)
---
@@ -723,14 +738,3 @@ 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.
---
+2
View File
@@ -300,6 +300,8 @@ func main() {
mux.HandleFunc("/api/cars", carsRouter(cat))
mux.HandleFunc("/api/cars/", carByIDHandler(cat))
mux.HandleFunc("/api/races", racesListHandler(racesSvc))
mux.HandleFunc("/api/races/last", racesGetLastHandler(racesSvc))
mux.HandleFunc("/api/races/", racesGetHandler(racesSvc))
mux.HandleFunc("/api/races/upcoming", racesUpcomingHandler(racesSvc))
mux.HandleFunc("/api/races/queue", racesQueueRouter(racesSvc))
mux.HandleFunc("/api/races/queue/join", racesQueueJoinHandler(racesSvc))
+94
View File
@@ -90,6 +90,100 @@ func racesListHandler(svc *races.Service) http.HandlerFunc {
}
}
// racesGetLastHandler godoc
// @Summary Get the results of the last finished race
// @Description Returns the details and podium/results of the most recently finished race.
// @Tags races
// @Produce json
// @Success 200 {object} transport.LobbyRace "Last race results"
// @Failure 404 {object} map[string]interface{} "No finished races found"
// @Failure 500 {object} map[string]interface{} "Internal server error"
// @Router /api/races/last [get]
func racesGetLastHandler(svc *races.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
res, err := svc.GetLastFinished(r.Context())
if err != nil {
if errors.Is(err, races.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "no finished races found")
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
var out transport.LobbyRace
meta := lobby.RaceMeta{
ID: res.ID,
Name: res.Name,
TrackID: res.TrackID,
MaxCars: res.MaxCars,
Laps: res.Laps,
TimeLimitS: res.TimeLimitS,
DriverIDs: res.DriverIDs,
Status: lobby.RaceStatus(res.Status),
CreatedMs: res.CreatedMs,
StartedMs: res.StartedMs,
}
if len(res.Podium) > 0 {
out = lobbyToWireFinished(meta, res.Podium)
} else {
out = lobbyToWire(meta)
}
writeJSON(w, http.StatusOK, out)
}
}
// racesGetHandler godoc
// @Summary Get results of a specific race by ID
// @Description Returns details of a single race by its ID. Works for both live and finished races.
// @Tags races
// @Produce json
// @Param id path string true "Race ID"
// @Success 200 {object} transport.LobbyRace "Race details"
// @Failure 400 {object} map[string]interface{} "Missing race id"
// @Failure 404 {object} map[string]interface{} "Race not found"
// @Failure 500 {object} map[string]interface{} "Internal server error"
// @Router /api/races/{id} [get]
func racesGetHandler(svc *races.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/races/")
if id == "" || strings.Contains(id, "/") {
writeError(w, http.StatusBadRequest, "bad_request", "missing race id")
return
}
res, err := svc.GetRace(r.Context(), id)
if err != nil {
if errors.Is(err, races.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "race not found")
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
var out transport.LobbyRace
meta := lobby.RaceMeta{
ID: res.ID,
Name: res.Name,
TrackID: res.TrackID,
MaxCars: res.MaxCars,
Laps: res.Laps,
TimeLimitS: res.TimeLimitS,
DriverIDs: res.DriverIDs,
Status: lobby.RaceStatus(res.Status),
CreatedMs: res.CreatedMs,
StartedMs: res.StartedMs,
}
if len(res.Podium) > 0 {
out = lobbyToWireFinished(meta, res.Podium)
} else {
out = lobbyToWire(meta)
}
writeJSON(w, http.StatusOK, out)
}
}
func parseRaceListFilter(r *http.Request) (races.ListFilter, error) {
q := r.URL.Query()
cur, err := races.DecodeCursor(q.Get("cursor"))
+84
View File
@@ -1192,6 +1192,40 @@ const docTemplate = `{
}
}
},
"/api/races/last": {
"get": {
"description": "Returns the details and podium/results of the most recently finished race.",
"produces": [
"application/json"
],
"tags": [
"races"
],
"summary": "Get the results of the last finished race",
"responses": {
"200": {
"description": "Last race results",
"schema": {
"$ref": "#/definitions/transport.LobbyRace"
}
},
"404": {
"description": "No finished races found",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/races/plans": {
"get": {
"description": "Returns race plans in ascending start_at order. Pass back the ` + "`" + `next_cursor` + "`" + ` to get the next page.",
@@ -1534,6 +1568,56 @@ const docTemplate = `{
}
}
},
"/api/races/{id}": {
"get": {
"description": "Returns details of a single race by its ID. Works for both live and finished races.",
"produces": [
"application/json"
],
"tags": [
"races"
],
"summary": "Get results of a specific race by ID",
"parameters": [
{
"type": "string",
"description": "Race ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Race details",
"schema": {
"$ref": "#/definitions/transport.LobbyRace"
}
},
"400": {
"description": "Missing race id",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Race not found",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/tracks": {
"get": {
"description": "Returns all tracks visible to the caller. Optional query filters: ` + "`" + `scope=system|public|private` + "`" + `, ` + "`" + `tag=\u003cstring\u003e` + "`" + `.",
+84
View File
@@ -1190,6 +1190,40 @@
}
}
},
"/api/races/last": {
"get": {
"description": "Returns the details and podium/results of the most recently finished race.",
"produces": [
"application/json"
],
"tags": [
"races"
],
"summary": "Get the results of the last finished race",
"responses": {
"200": {
"description": "Last race results",
"schema": {
"$ref": "#/definitions/transport.LobbyRace"
}
},
"404": {
"description": "No finished races found",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/races/plans": {
"get": {
"description": "Returns race plans in ascending start_at order. Pass back the `next_cursor` to get the next page.",
@@ -1532,6 +1566,56 @@
}
}
},
"/api/races/{id}": {
"get": {
"description": "Returns details of a single race by its ID. Works for both live and finished races.",
"produces": [
"application/json"
],
"tags": [
"races"
],
"summary": "Get results of a specific race by ID",
"parameters": [
{
"type": "string",
"description": "Race ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Race details",
"schema": {
"$ref": "#/definitions/transport.LobbyRace"
}
},
"400": {
"description": "Missing race id",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Race not found",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/tracks": {
"get": {
"description": "Returns all tracks visible to the caller. Optional query filters: `scope=system|public|private`, `tag=\u003cstring\u003e`.",
+59
View File
@@ -1660,6 +1660,65 @@ paths:
summary: List races (keyset paginated)
tags:
- races
/api/races/{id}:
get:
description: Returns details of a single race by its ID. Works for both live
and finished races.
parameters:
- description: Race ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: Race details
schema:
$ref: '#/definitions/transport.LobbyRace'
"400":
description: Missing race id
schema:
additionalProperties: true
type: object
"404":
description: Race not found
schema:
additionalProperties: true
type: object
"500":
description: Internal server error
schema:
additionalProperties: true
type: object
summary: Get results of a specific race by ID
tags:
- races
/api/races/last:
get:
description: Returns the details and podium/results of the most recently finished
race.
produces:
- application/json
responses:
"200":
description: Last race results
schema:
$ref: '#/definitions/transport.LobbyRace'
"404":
description: No finished races found
schema:
additionalProperties: true
type: object
"500":
description: Internal server error
schema:
additionalProperties: true
type: object
summary: Get the results of the last finished race
tags:
- races
/api/races/plans:
get:
description: Returns race plans in ascending start_at order. Pass back the `next_cursor`
+85
View File
@@ -308,3 +308,88 @@ func TestLeaderboard(t *testing.T) {
}
})
}
func TestGetRaceAndLastFinished(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL is not set, skipping database integration tests")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("failed to connect to test database: %v", err)
}
defer pool.Close()
store := NewPgStore(pool)
cleanup := func() {
_, _ = pool.Exec(ctx, "DELETE FROM race_drivers WHERE race_id LIKE 'test-get-race-%'")
_, _ = pool.Exec(ctx, "DELETE FROM races WHERE id LIKE 'test-get-race-%'")
}
cleanup()
defer cleanup()
// 1. Insert a finished race
now := time.Now().UnixMilli()
race := FinishedRace{
ID: "test-get-race-1",
Name: "Test Get Race 1",
TrackID: "monaco",
MaxCars: 4,
Laps: 5,
TimeLimitS: 300,
Status: "finished",
CreatedMs: now - 10000,
StartedMs: now - 8000,
FinishedMs: now - 5000,
DurationMs: 3000,
TotalLaps: 20,
TotalDrivers: 1,
}
err = store.InsertFinished(ctx, race)
if err != nil {
t.Fatalf("failed to insert finished race: %v", err)
}
// 2. Fetch the race by ID
got, err := store.GetFinished(ctx, "test-get-race-1")
if err != nil {
t.Fatalf("GetFinished failed: %v", err)
}
if got.ID != "test-get-race-1" || got.Name != "Test Get Race 1" {
t.Errorf("expected test-get-race-1, got: %+v", got)
}
// 3. Insert a second finished race (newer finished_ms)
race2 := FinishedRace{
ID: "test-get-race-2",
Name: "Test Get Race 2",
TrackID: "monaco",
MaxCars: 4,
Laps: 5,
TimeLimitS: 300,
Status: "finished",
CreatedMs: now - 5000,
StartedMs: now - 4000,
FinishedMs: now - 1000,
DurationMs: 3000,
TotalLaps: 20,
TotalDrivers: 1,
}
err = store.InsertFinished(ctx, race2)
if err != nil {
t.Fatalf("failed to insert finished race 2: %v", err)
}
// 4. Fetch the last finished race (should be test-get-race-2)
last, err := store.GetLastFinished(ctx)
if err != nil {
t.Fatalf("GetLastFinished failed: %v", err)
}
if last.ID != "test-get-race-2" {
t.Errorf("expected test-get-race-2 as last race, got: %s", last.ID)
}
}
+25
View File
@@ -812,6 +812,31 @@ func (s *Service) GetCalendarLeaderboard(ctx context.Context, year int, currentD
return out, nil
}
// GetRace fetches a single race by id (either live from lobby or finished from DB).
func (s *Service) GetRace(ctx context.Context, id string) (FinishedRace, error) {
meta, err := s.lobby.GetRace(id)
if err == nil {
return FinishedRace{
ID: meta.ID,
Name: meta.Name,
TrackID: meta.TrackID,
MaxCars: meta.MaxCars,
Laps: meta.Laps,
TimeLimitS: meta.TimeLimitS,
DriverIDs: meta.DriverIDs,
Status: string(meta.Status),
CreatedMs: meta.CreatedMs,
StartedMs: meta.StartedMs,
}, nil
}
return s.pg.GetFinished(ctx, id)
}
// GetLastFinished fetches the most recently finished race.
func (s *Service) GetLastFinished(ctx context.Context) (FinishedRace, error) {
return s.pg.GetLastFinished(ctx)
}
// ---------------------------------------------------------------------------
// Scheduler
// ---------------------------------------------------------------------------
+55
View File
@@ -696,6 +696,61 @@ func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error {
return nil
}
// GetFinished fetches a single finished race by id.
func (s *PgStore) GetFinished(ctx context.Context, id string) (FinishedRace, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, name, track_id, max_cars, laps, time_limit_s,
status, created_ms, started_ms, finished_ms, duration_ms,
total_laps, total_drivers
FROM races
WHERE id = $1 AND status IN ('finished', 'cancelled')`, id)
var r FinishedRace
if err := row.Scan(
&r.ID, &r.Name, &r.TrackID,
&r.MaxCars, &r.Laps, &r.TimeLimitS, &r.Status,
&r.CreatedMs, &r.StartedMs, &r.FinishedMs, &r.DurationMs,
&r.TotalLaps, &r.TotalDrivers,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return FinishedRace{}, ErrNotFound
}
return FinishedRace{}, err
}
if err := s.hydrateFinished(ctx, &r); err != nil {
return FinishedRace{}, err
}
return r, nil
}
// GetLastFinished fetches the most recently finished race.
func (s *PgStore) GetLastFinished(ctx context.Context) (FinishedRace, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, name, track_id, max_cars, laps, time_limit_s,
status, created_ms, started_ms, finished_ms, duration_ms,
total_laps, total_drivers
FROM races
WHERE status IN ('finished', 'cancelled')
ORDER BY finished_ms DESC, id DESC
LIMIT 1`)
var r FinishedRace
if err := row.Scan(
&r.ID, &r.Name, &r.TrackID,
&r.MaxCars, &r.Laps, &r.TimeLimitS, &r.Status,
&r.CreatedMs, &r.StartedMs, &r.FinishedMs, &r.DurationMs,
&r.TotalLaps, &r.TotalDrivers,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return FinishedRace{}, ErrNotFound
}
return FinishedRace{}, err
}
if err := s.hydrateFinished(ctx, &r); err != nil {
return FinishedRace{}, err
}
return r, nil
}
func joinAnd(parts []string) string {
out := ""
for i, p := range parts {