From 9c9632343d17c7ce141032e931e14fbf4c98e7b4 Mon Sep 17 00:00:00 2001 From: IS1DI Date: Fri, 17 Jul 2026 22:41:55 +0400 Subject: [PATCH] Add races results GET endpoints (by ID and last finished) and regenerate swagger specs --- AGENTS.md | 2 + server/CODEBASE.md | 640 +++++++++++----------- server/cmd/poc-server/main.go | 2 + server/cmd/poc-server/races_handlers.go | 94 ++++ server/docs/docs.go | 84 +++ server/docs/swagger.json | 84 +++ server/docs/swagger.yaml | 59 ++ server/internal/races/leaderboard_test.go | 85 +++ server/internal/races/service.go | 25 + server/internal/races/store.go | 55 ++ 10 files changed, 812 insertions(+), 318 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9a638f9..2237c8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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=...` — посмотреть. diff --git a/server/CODEBASE.md b/server/CODEBASE.md index 852887d..0bb2d0c 100644 --- a/server/CODEBASE.md +++ b/server/CODEBASE.md @@ -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. - ---- - diff --git a/server/cmd/poc-server/main.go b/server/cmd/poc-server/main.go index bc2a5ba..cf479c6 100644 --- a/server/cmd/poc-server/main.go +++ b/server/cmd/poc-server/main.go @@ -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)) diff --git a/server/cmd/poc-server/races_handlers.go b/server/cmd/poc-server/races_handlers.go index beb9033..02e4d1e 100644 --- a/server/cmd/poc-server/races_handlers.go +++ b/server/cmd/poc-server/races_handlers.go @@ -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")) diff --git a/server/docs/docs.go b/server/docs/docs.go index 9e10e07..c1b911d 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -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` + "`" + `.", diff --git a/server/docs/swagger.json b/server/docs/swagger.json index e6c0ae4..026b441 100644 --- a/server/docs/swagger.json +++ b/server/docs/swagger.json @@ -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`.", diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index 64f86d0..da6383b 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -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` diff --git a/server/internal/races/leaderboard_test.go b/server/internal/races/leaderboard_test.go index 456e2cc..779cc61 100644 --- a/server/internal/races/leaderboard_test.go +++ b/server/internal/races/leaderboard_test.go @@ -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) + } +} + diff --git a/server/internal/races/service.go b/server/internal/races/service.go index 4abe847..f6af10e 100644 --- a/server/internal/races/service.go +++ b/server/internal/races/service.go @@ -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 // --------------------------------------------------------------------------- diff --git a/server/internal/races/store.go b/server/internal/races/store.go index 5df7fae..770fd80 100644 --- a/server/internal/races/store.go +++ b/server/internal/races/store.go @@ -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 {