mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-18 13:27:03 +00:00
Add races results GET endpoints (by ID and last finished) and regenerate swagger specs
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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` + "`" + `.",
|
||||
|
||||
@@ -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`.",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user