6 Commits
35 changed files with 4038 additions and 76 deletions
+2
View File
@@ -11,3 +11,5 @@ server/poc-server.exe
.grepai/
*.blend1
*.blend2
node_modules/
+15 -1
View File
@@ -43,6 +43,17 @@
- Go 1.26, бинарь `cmd/poc-server`. Сборка: `go build ./...` или `go build -o poc-server.exe ./cmd/poc-server`.
- HTTP+WebSocket, роутер `http.NewServeMux`. Запуск требует `DATABASE_URL` (см. `.env`).
- **База данных (Supabase)**:
- Используется self-hosted Supabase на порту `3402` (хост: `80.234.93.161:3402`).
- Расширение `timescaledb` отключено (закомментировано в `001_init.sql`), так как Supabase не поставляет его из коробки.
- Нативная папка `supabase/` содержит `config.toml`, объединенный дамп схемы `supabase/migrations/20260717000000_schema.sql` и каталог семян `supabase/seed.sql` (включает рескейл Монако).
- Объединенный дамп схемы автоматически заполняет таблицу `schema_migrations`, чтобы предотвратить ошибки повторного применения миграций Go-сервером на старте.
- **Абстракция данных (Store)**:
- Пакеты `clans` и `drivers` используют интерфейсы `Store` вместо прямого импорта `*PgStore`.
- Это позволяет писать юнит-тесты сервисов без подключения к реальной БД (через `memoryStore`).
- **Слепок структуры (Graphify)**:
- Утилита `scripts/graphify/main.go` собирает описание всех экспортируемых пакетов, структур, интерфейсов и функций в компактный файл [server/CODEBASE.md](file:///d:/x0gp/server/CODEBASE.md).
- Рекомендуется запускать `go run ./scripts/graphify/main.go` после значимых изменений API для поддержания карты проекта в актуальном состоянии и экономии токенов при чтении агентом.
- **Swagger UI**: `GET /swagger/index.html`, спека `GET /swagger/doc.json` (14 paths, 29 definitions).
- Генерация спеки: `swag init -g cmd/poc-server/main.go -o docs --parseInternal` из `D:/x0gp/server`. CLI ставится через `go install github.com/swaggo/swag/cmd/swag@v1.16.4`.
- Аннотации swag живут над хендлерами в `cmd/poc-server/main.go`, `catalog_handlers.go`, `races_handlers.go`. При добавлении нового REST-эндпоинта — добавить `@Router`/`@Param`/`@Success` и перегенерить `docs/`.
@@ -56,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=...` — посмотреть.
@@ -69,7 +82,8 @@
- CRUD:
- `POST/GET /api/clans`, `GET/PUT/DELETE /api/clans/{id}`, `GET /api/clans/by-tag/{tag}`
- `POST/GET /api/drivers`, `GET/PUT/DELETE /api/drivers/{id}`, `GET /api/drivers/by-nick/{nick}`
- `PUT /api/drivers/{id}` принимает `clan_id` как `*string` (nil = оставить, "" = очистить, иначе = назначить).
- `GET /api/drivers/{id}/profile` — профиль с агрегированной статистикой (всего стартов/финишей, победы, подиумы, лучшее время круга, суммарное время на треке) и сводками лучшей и последней завершенной гонки. Можно вызвать как `/api/drivers/me/profile` для получения профиля авторизованного гонщика.
- `PUT /api/drivers/{id}` принимает `nickname` (3 уникальные заглавные латинские буквы), `name`, `avatar_url` и `clan_id` как `*string` (nil = оставить, "" = очистить, иначе = назначить). Редактирование и удаление чужого профиля запрещено при `DevMode = false`.
- Сидер (через `--seed-races`) заполняет 3 клана и 8 водителей, треки берутся из `tracks` каталога (никаких выдуманных id в `finished_races`/`race_plans`).
- Podium в `LobbyRace` заполняется только для finished-гонок (top-3: position, driver_id, name, total_time_ms).
+233
View File
@@ -0,0 +1,233 @@
{
"name": "x0gp",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"supabase": "^2.109.1"
}
},
"node_modules/@ecies/ciphers": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz",
"integrity": "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==",
"dev": true,
"license": "MIT",
"engines": {
"bun": ">=1",
"deno": ">=2.7.10",
"node": ">=16"
},
"peerDependencies": {
"@noble/ciphers": "^1.0.0"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@supabase/cli-darwin-arm64": {
"version": "2.109.1",
"resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.109.1.tgz",
"integrity": "sha512-tkn8tfunyqIL7RE+7DVjg6Ql2cJLPkGgh9cPafp2LbXI0qDgds0TaS+UOTHQEjci8JQXXe2wS00+122ko2QI8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@supabase/cli-darwin-x64": {
"version": "2.109.1",
"resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.109.1.tgz",
"integrity": "sha512-0Q5ZAoWhOyIv4ZzQOU8QbjjdB2JmznnDNyJ3VrIeuLMWoifVfUWaLZhHBNYzr4xXoxBpKrggomuAT8BaEhY3lg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@supabase/cli-linux-arm64": {
"version": "2.109.1",
"resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.109.1.tgz",
"integrity": "sha512-MS1djJjq5laD99+jYJUoARfSZhzRX9aXmo5piZ1yl2JXb9IXTuhiTD5olkFKQcgNckRcAZqMf4fucQGTYC767A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@supabase/cli-linux-arm64-musl": {
"version": "2.109.1",
"resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.109.1.tgz",
"integrity": "sha512-cXNOsSU7MS+jmslGQATTD4DfWBEIHiFi7LaBWyBxHmR7tzIiZXimUXgN26ZiQjfAxU+RZq52C3k0qhi7vmqXQw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@supabase/cli-linux-x64": {
"version": "2.109.1",
"resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.109.1.tgz",
"integrity": "sha512-svFmamF/vIq4/oinwY50jDi869itC9/GWrPaGtsHFkK4NUBcQtl1T37WWIivGsXwbBKNC4FjZD3dGqjL7bfW1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@supabase/cli-linux-x64-musl": {
"version": "2.109.1",
"resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.109.1.tgz",
"integrity": "sha512-zeD9MrZpEKJrjkSPcYp4nZeGJ9FQt0i/kiRij4ZprPP6R5l+SLi6Pk20ln+Vj/GZuWTVxX7IHJ11pgViaReAWw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@supabase/cli-windows-arm64": {
"version": "2.109.1",
"resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.109.1.tgz",
"integrity": "sha512-NeKzgWAOpglnLwMTggTp5t44fbkfxACLkQNlCRx0q332HMM3WqsKQUsHv1MHc6ZbEc5bcMwaYjqPNI/aOWnP5g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@supabase/cli-windows-x64": {
"version": "2.109.1",
"resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.109.1.tgz",
"integrity": "sha512-L9/pDLM+4IR8646aT69ZDVcnJeg1lZZsB26ZgI775S3f8jOHP41+1UH9Oq1g9CvKiAQviuaLgtwkuvi0I/cc/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/eciesjs": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.5.0.tgz",
"integrity": "sha512-s0J9SEVYAEPg7J63GFMApLYzPH9VNIQIyC6s15JpnqVc0TqcKWdbgFlnAweEBRyMmko2dcs2sfC83Hj4J43tuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ecies/ciphers": "^0.2.6",
"@noble/ciphers": "^1.3.0",
"@noble/curves": "^1.9.7",
"@noble/hashes": "^1.8.0"
},
"engines": {
"bun": ">=1",
"deno": ">=2.7.10",
"node": ">=16"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/supabase": {
"version": "2.109.1",
"resolved": "https://registry.npmjs.org/supabase/-/supabase-2.109.1.tgz",
"integrity": "sha512-N2yP2MHTxOxXBWhfn3poudpJn4pkPosAUo7J/46FTou/l7wOwFi9tox8NSN6HljWkfM0zhwPRimNNGC9XBMoxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"eciesjs": "^0.5.0",
"jose": "^6.2.3"
},
"bin": {
"supabase": "dist/supabase.js"
},
"optionalDependencies": {
"@supabase/cli-darwin-arm64": "2.109.1",
"@supabase/cli-darwin-x64": "2.109.1",
"@supabase/cli-linux-arm64": "2.109.1",
"@supabase/cli-linux-arm64-musl": "2.109.1",
"@supabase/cli-linux-x64": "2.109.1",
"@supabase/cli-linux-x64-musl": "2.109.1",
"@supabase/cli-windows-arm64": "2.109.1",
"@supabase/cli-windows-x64": "2.109.1"
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"devDependencies": {
"supabase": "^2.109.1"
}
}
+3 -3
View File
@@ -9,7 +9,7 @@
# postgres://x0gp:x0gp@localhost:5432/x0gp?sslmode=disable
# postgres://x0gp:x0gp@postgres:5432/x0gp?sslmode=disable (inside compose)
DATABASE_URL=postgres://x0gp:x0gp@localhost:5432/x0gp?sslmode=disable
DATABASE_URL=postgres://postgres:postgres@localhost:3402/postgres?sslmode=disable
# HTTP server.
X0GP_HTTP_ADDR=:8080
@@ -27,5 +27,5 @@ X0GP_SEED_RESET=0
# PoC: skip auth, allow multiple clients on the same track.
X0GP_DEV_MODE=true
# Optional: override path(s) to dotenv files (colon-separated).
# X0GP_DOTENV=.env:./configs/local.env
# JWT secret (Supabase service role or JWT secret). Required when X0GP_DEV_MODE=false.
X0GP_JWT_SECRET=your-supabase-jwt-secret-here
+764
View File
@@ -0,0 +1,764 @@
# x0gp Go Codebase Map (Generated)
This file contains a compiled map of all packages, structures, interfaces, and public functions in the `server` directory. It is optimized for token-efficiency so AI agents and humans can understand the codebase layout without reading all files.
## Package `main` (`cmd/poc-server`)
HTTP handlers for the catalog (tracks + cars).
All HTTP routes live under /api/*:
GET /api/tracks list tracks (optional ?scope=system|public|private&tag=)
GET /api/tracks/{id} get a single track
POST /api/tracks create a track
PUT /api/tracks/{id} update a track
DELETE /api/tracks/{id} delete a track
GET /api/cars list cars (optional ?scope=...&owner_id=...)
GET /api/cars/{id} get a single car
POST /api/cars create a car
PUT /api/cars/{id} update a car
DELETE /api/cars/{id} delete a car
GET /api/catalog combined snapshot + summary
WebSocket access is wired up directly in main.go's readPump switch.
### Structs
- **struct Hub**
- **struct UDPVideoService**
- **struct WebRTCService**
### Functions
- func NewHub(*slog.Logger) *Hub
- func NewUDPVideoService(*slog.Logger, string, int) *UDPVideoService
- func NewWebRTCService(*slog.Logger, *control.Engine, *lobby.Service, *UDPVideoService, *catalog.Service) *WebRTCService
### Methods
- func (*catalogBroadcaster) Run()
- func (*catalogBroadcaster) Stop()
- func (*Hub) BroadcastSnapshot(transport.RaceSnapshot)
- func (*statusRecorder) WriteHeader(int)
- func (*statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error)
- func (*UDPVideoService) Start(context.Context, *sync.WaitGroup)
- func (*UDPVideoService) SendControlPacket(uint8, []byte) error
- func (*UDPVideoService) SendCommand(uint8, string) error
- func (*UDPVideoService) StreamHandler() http.HandlerFunc
- func (*UDPVideoService) ControlHandler() http.HandlerFunc
- func (*WebRTCService) Start(context.Context, *sync.WaitGroup)
- func (*WebRTCService) ConnectHandler() http.HandlerFunc
- func (*WebRTCService) CloseSession(string)
- func (*WebRTCService) CloseAll()
---
## Package `clans` (`internal/clans`)
Package clans owns persistence and business logic for clans.
A clan has a unique 3-letter tag (A-Z, uppercase, exactly 3 chars),
a name, and an optional avatar URL. Drivers reference a clan via a
nullable FK (see internal/drivers).
### Interfaces
- **interface Store** { Exec, Create, Get, GetByTag, List, Delete, Update, Count }
### Structs
- **struct Service**
- **struct CreateInput** { ID, Tag, Name, AvatarURL }
- **struct UpdateInput** { Name, AvatarURL }
- **struct Clan** { ID, Tag, Name, AvatarURL, CreatedMs, UpdatedMs }
- **struct PgStore**
### Functions
- func NewService(Store) *Service
- func IsValidTag(string) bool
- func NewPgStore(*pgxpool.Pool) *PgStore
### Methods
- func (*Service) Create(context.Context, CreateInput) (Clan, error)
- func (*Service) Get(context.Context, string) (Clan, error)
- func (*Service) GetByTag(context.Context, string) (Clan, error)
- func (*Service) List(context.Context, int, int) ([]Clan, error)
- func (*Service) Update(context.Context, string, UpdateInput) (Clan, error)
- func (*Service) Delete(context.Context, string) error
- func (*PgStore) Exec(context.Context, string) (int64, error)
- func (*PgStore) Create(context.Context, Clan) error
- func (*PgStore) Get(context.Context, string) (Clan, error)
- func (*PgStore) GetByTag(context.Context, string) (Clan, error)
- func (*PgStore) List(context.Context, int, int) ([]Clan, error)
- func (*PgStore) Delete(context.Context, string) error
- func (*PgStore) Update(context.Context, Clan) 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 `races` (`internal/races`)
Package races owns the cross-cutting "race list" surface: keyset-paginated
listing of live and finished races, upcoming (next-N) selection, the
driver-to-race queue and the recurring race-plan scheduler.
The package composes two storage backends:
- lobby.Service (in-memory): authoritative source for active races
(status = lobby | countdown | racing). Used directly for the live
page of /api/races, for the upcoming view and for the scheduler
materialisation.
- Postgres (pgxpool): authoritative for finished races (snapshotted
on SetRaceStatus(finished)), for race plans and for the queue.
Finished races are read with keyset pagination on
(finished_ms DESC, id DESC).
### Structs
- **struct Cursor** { Ms, ID }
- **struct LeaderboardEntry** { DriverID, Name, Nickname, AvatarURL, ClanID, ClanTag, Points, BestPos, BestTimeMs, Rank }
- **struct LeaderboardResult** { Items, Total, CurrentDriver }
- **struct TrackCalendarInfo** { ID, TrackID, TrackName, StartAt, EndAt }
- **struct TrackCalendarLeaderboard** { TrackID, TrackName, StartAt, EndAt, Status, Podium, CurrentDriver }
- **struct LiveStore**
- **struct Service**
- **struct ListFilter** { Statuses, TrackID, Cursor, Limit }
- **struct RaceItem** { Source, Meta, Podium }
- **struct ListResult** { Items, NextCursor, TotalCount }
- **struct UpcomingItem** { Meta, QueueLen, PlanID, StartAtMs }
- **struct JoinUpcomingResult** { Joined, Skipped }
- **struct SnapshotFinished** { Meta, FinishedMs, TotalLaps, WinnerDriverID, WinnerName, BestLapMs, Results }
- **struct CreatePlanInput** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, StartAtMs, IntervalS, Count, Enabled }
- **struct Scheduler**
- **struct PodiumEntry** { Position, DriverID, Name, TotalTimeMs, Nickname, AvatarUrl, ClanID }
- **struct DriverResult** { DriverID, Name, TotalTimeMs, BestLapMs, Position, Nickname, AvatarUrl, ClanID }
- **struct FinishedRace** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, DriverIDs, Status, CreatedMs, StartedMs, FinishedMs, DurationMs, TotalLaps, TotalDrivers, Results, WinnerDriverID, WinnerName, BestLapMs, Podium }
- **struct RacePlan** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, StartAtMs, IntervalS, Count, Enabled, CreatedMs, UpdatedMs, NextFireMs, FiresDone }
- **struct QueueEntry** { DriverID, RaceID, PlanID, EnqueuedMs }
- **struct PgStore**
- **struct DriverStats** { TotalRacesStarted, TotalRacesFinished, Wins, Podiums, BestLapMs, TotalPlaytimeMs }
- **struct DriverRaceSummary** { RaceID, RaceName, FinishedMs, Position, TotalTimeMs, BestLapMs, TrackID, TrackName }
### Functions
- func DecodeCursor(string) (Cursor, error)
- func NewLiveStore(*PgStore) *LiveStore
- func NewService(*PgStore, *lobby.Service) *Service
- func NewScheduler(*PgStore, *lobby.Service, time.Duration) *Scheduler
- func NewPgStore(*pgxpool.Pool) *PgStore
- func ParseStatusFilter(string) ([]StatusFilter, error)
### Methods
- func (Cursor) String() string
- func (Cursor) IsZero() bool
- func (Cursor) Encode() string
- func (*PgStore) GetTrackLeaderboard(context.Context, string, int, int, int) ([]LeaderboardEntry, int, error)
- func (*PgStore) GetTrackLeaderboardDriver(context.Context, string, int, string) (*LeaderboardEntry, error)
- func (*PgStore) GetOverallLeaderboard(context.Context, int, int, int) ([]LeaderboardEntry, int, error)
- func (*PgStore) GetOverallLeaderboardDriver(context.Context, int, string) (*LeaderboardEntry, error)
- func (*PgStore) GetDriverLeaderboardProfile(context.Context, string) (*LeaderboardEntry, error)
- func (*PgStore) GetCalendarLeaderboardTracks(context.Context) ([]TrackCalendarInfo, error)
- func (*LiveStore) UpsertRace(context.Context, lobby.RaceMeta) error
- func (*LiveStore) DeleteRace(context.Context, string) error
- func (*LiveStore) AddDriver(context.Context, string, string, int) error
- func (*LiveStore) RemoveDriver(context.Context, string, string) error
- func (*LiveStore) SetRaceStatus(context.Context, string, lobby.RaceStatus, int64) error
- func (*LiveStore) UpsertDriver(context.Context, lobby.DriverMeta) error
- func (*LiveStore) DeleteDriver(context.Context, string) error
- func (*LiveStore) ListAllRaces(context.Context) ([]lobby.RaceMeta, error)
- func (*LiveStore) ListAllDrivers(context.Context) ([]lobby.DriverMeta, error)
- func (*LiveStore) ListLivePaged(context.Context, []string, string, Cursor, int) ([]lobby.RaceMeta, error)
- func (*LiveStore) ListUpcoming(context.Context, int) ([]lobby.RaceMeta, error)
- func (*LiveStore) Exec(context.Context, string) (int64, error)
- func (*Service) SetLiveStore(*LiveStore)
- func (*Service) RestoreFromDB(context.Context) (int, int, error)
- func (*Service) SetMaxUpcoming(int)
- func (*Service) ListRaces(context.Context, ListFilter) (ListResult, error)
- func (*Service) Upcoming(context.Context, int) ([]UpcomingItem, error)
- func (*Service) JoinUpcoming(context.Context, string, int) (JoinUpcomingResult, error)
- func (*Service) LeaveQueue(context.Context, string, string) error
- func (*Service) ListQueue(context.Context, string) ([]QueueEntry, error)
- func (*Service) PersistFinished(context.Context, SnapshotFinished) error
- func (*Service) CreatePlan(context.Context, CreatePlanInput) (RacePlan, error)
- func (*Service) ListPlans(context.Context, Cursor, int) ([]RacePlan, error)
- func (*Service) CountPlans(context.Context) (int, error)
- 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 (*Service) GetDriverProfileStats(context.Context, string) (DriverStats, *DriverRaceSummary, *DriverRaceSummary, error)
- func (*Scheduler) Run(context.Context)
- func (*Scheduler) Stop()
- func (*Scheduler) Done() ?
- func (*PgStore) Exec(context.Context, string, ?) (int64, error)
- func (*PgStore) ListTrackIDs(context.Context) ([]string, error)
- func (*PgStore) UpsertLive(context.Context, lobby.RaceMeta) error
- func (*PgStore) DeleteLive(context.Context, string) error
- func (*PgStore) AddLiveDriver(context.Context, string, string, int) error
- func (*PgStore) RemoveLiveDriver(context.Context, string, string) error
- func (*PgStore) SetLiveStatus(context.Context, string, lobby.RaceStatus, int64) error
- func (*PgStore) ListLivePaged(context.Context, []string, string, Cursor, int) ([]lobby.RaceMeta, error)
- func (*PgStore) ListLiveUpcoming(context.Context, int) ([]lobby.RaceMeta, error)
- func (*PgStore) GetLive(context.Context, string) (lobby.RaceMeta, error)
- func (*PgStore) ListAllRaces(context.Context) ([]lobby.RaceMeta, error)
- func (*PgStore) UpsertLobbyDriver(context.Context, lobby.DriverMeta) error
- func (*PgStore) DeleteLobbyDriver(context.Context, string) error
- func (*PgStore) ListLobbyDrivers(context.Context) ([]lobby.DriverMeta, error)
- 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)
- func (*PgStore) CountPlans(context.Context) (int, error)
- func (*PgStore) DeletePlan(context.Context, string) error
- func (*PgStore) SetPlanEnabled(context.Context, string, bool) error
- func (*PgStore) AdvancePlan(context.Context, string, int64) error
- func (*PgStore) DuePlans(context.Context, int64, int) ([]RacePlan, error)
- func (*PgStore) Enqueue(context.Context, string, string, string) (QueueEntry, error)
- func (*PgStore) Dequeue(context.Context, string, string) error
- func (*PgStore) ListQueueByDriver(context.Context, string) ([]QueueEntry, error)
- func (*PgStore) ListQueueByRace(context.Context, string) ([]QueueEntry, error)
- func (*PgStore) CountQueueByRace(context.Context, string) (int, error)
- func (FinishedRace) ToLobbyMeta() lobby.RaceMeta
- func (*PgStore) GetDriverStats(context.Context, string) (DriverStats, error)
- func (*PgStore) GetDriverLastRace(context.Context, string) (*DriverRaceSummary, error)
- func (*PgStore) GetDriverBestRace(context.Context, string) (*DriverRaceSummary, error)
---
## Package `main` (`scripts/genseed`)
Package main contains the genseed utility. It is built into a standalone
binary via `go build ./scripts/genseed` and is not part of the runtime.
Usage:
go run ./scripts/genseed seeds.json > 002_seed.sql
It reads the JSON dump produced by `DUMP_SEEDS=1 go test ./internal/catalog`
and emits ON CONFLICT-safe INSERTs for the catalog tables.
---
## Package `main` (`scripts/genseed-json`)
Package main contains a small helper that prints the default seeds
(5 F1 tracks + 5 F1 cars) as JSON to stdout. It is run as:
go run ./scripts/genseed-json > seeds.json
which is then fed into scripts/genseed to regenerate the SQL.
---
## Package `main` (`scripts/graphify`)
Package main is the graphify utility. It walks the codebase, parses Go AST,
and outputs a highly compact symbol summary to server/CODEBASE.md.
This is used for token savings and helping developers/AI agents quickly map out imports and structures.
### Structs
- **struct PackageSummary** { Name, Path, Doc, Structs, Interfaces, Functions, Methods }
---
## Package `auth` (`internal/auth`)
Package auth handles Supabase JWT validation and HTTP middleware.
### Structs
- **struct Claims** { DriverID, Email }
### Functions
- func ValidateJWT(string, string) (Claims, error)
- func Middleware(string, bool) func
- func DriverIDFromContext(context.Context) (string, bool)
- func EmailFromContext(context.Context) (string, bool)
---
## Package `main` (`.`)
Smoke test: ws client connects, exchanges a few messages, exits.
Run: go run ./smoke_test.go
---
## Package `seeddefs` (`internal/catalog/seeddefs`)
Package seeddefs holds the canonical seed definitions for tracks and
cars. It lives in its own package (no postgres dependencies) so both
the catalog tests and the scripts/genseed SQL generator can consume
the same source-of-truth data.
### Structs
- **struct Waypoint** { X, Y, HeadingRad, SpeedMs, Curvature }
- **struct Bounds** { MinX, MinY, MaxX, MaxY, LengthM, WidthM }
- **struct Track** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline, Bounds, BestLapMs, BestLapHolder }
- **struct Chassis** { Model, Material, Printed, WheelbaseMm, TrackMm }
- **struct Motor** { Kind, Class, KV, PowerW }
- **struct Battery** { VoltageV, CapacityMah, Cells, Chemistry }
- **struct Car** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, TopSpeedMs, ColorHex, Active, TotalDistanceM, TotalRaces, TotalLaps, BestLapMs }
### Functions
- func DefaultTracks() []Track
- func DefaultCars() []Car
- func Monaco() Track
- func BarcelonaCatalunya() Track
- func AustriaRedBullRing() Track
- func GreatBritainSilverstone() Track
- func BelgiumSpa() Track
- func RedBullRB20() Car
- func FerrariSF24() Car
- func McLarenMCL38() Car
- func MercedesW15() Car
- func AstonMartinAMR24() Car
- func ComputeBounds([]Waypoint) Bounds
---
## Package `control` (`internal/control`)
Package control owns the authoritative race state.
For PoC, the physics model is intentionally simple (no tire slip, no
collision): each car integrates position from (throttle, brake, steering).
Realistic physics, anti-cheat checks, and CV-based ground truth will be
added in MVP / production phases.
### Structs
- **struct Car** { ID, DriverID, DriverName, DeviceID, X, Y, Heading, Speed, Lap, Sector, LastLapMs, BestLapMs, DNF, AppliedSteering, AppliedThrottle, AppliedBrake }
- **struct Engine**
### Functions
- func NewEngine(int) *Engine
### Methods
- func (RacePhase) String() string
- func (*Car) ApplyInput(transport.InputState, float64)
- func (*Car) Snapshot() transport.CarInfo
- func (*Engine) Snapshots() ?
- func (*Engine) Phase() RacePhase
- func (*Engine) SetPhase(RacePhase)
- func (*Engine) AddCar(string, string, int, *int) (*Car, error)
- func (*Engine) RemoveCar(string)
- func (*Engine) GetCarByDriver(string) *Car
- func (*Engine) Run(context.Context)
- func (*Engine) Stop()
---
## Package `drivers` (`internal/drivers`)
Package drivers owns persistence and business logic for drivers
(racers). Each driver has a unique 3-letter nickname (A-Z,
uppercase, exactly 3 chars), a name, an optional avatar URL and an
optional clan reference.
### Interfaces
- **interface Store** { Exec, Create, Get, GetByNickname, List, Delete, Update, Count }
### Structs
- **struct Service**
- **struct CreateInput** { ID, Nickname, Name, AvatarURL, ClanID }
- **struct UpdateInput** { Nickname, Name, AvatarURL, ClanID }
- **struct Driver** { ID, Nickname, Name, AvatarURL, ClanID, CreatedMs, UpdatedMs }
- **struct PgStore**
### Functions
- func NewService(Store) *Service
- func IsValidNickname(string) bool
- func NewPgStore(*pgxpool.Pool) *PgStore
### Methods
- func (*Service) Create(context.Context, CreateInput) (Driver, error)
- func (*Service) Get(context.Context, string) (Driver, error)
- func (*Service) GetByNickname(context.Context, string) (Driver, error)
- func (*Service) List(context.Context, int, int, string) ([]Driver, error)
- func (*Service) Update(context.Context, string, UpdateInput) (Driver, error)
- func (*Service) Delete(context.Context, string) error
- func (*PgStore) Exec(context.Context, string) (int64, error)
- func (*PgStore) Create(context.Context, Driver) error
- func (*PgStore) Get(context.Context, string) (Driver, error)
- func (*PgStore) GetByNickname(context.Context, string) (Driver, error)
- func (*PgStore) List(context.Context, int, int, string) ([]Driver, error)
- func (*PgStore) Update(context.Context, Driver) error
- func (*PgStore) Delete(context.Context, string) error
- func (*PgStore) Count(context.Context) (int, error)
---
## Package `realtime` (`internal/realtime`)
Package realtime manages WebSocket connections: registration, fan-out of
race snapshots, and per-client send queues with backpressure handling.
### Structs
- **struct Client** { ID, Send, SessionID, Done, DriverID, LastUDPCommand }
- **struct Hub**
- **struct Stats** { Connections, DropsTotal }
### Functions
- func NewHub() *Hub
- func MustEnvelope(transport.MessageType, any) *transport.Envelope
### Methods
- func (*Client) Close()
- func (*Hub) Stats() Stats
- func (*Hub) Register(*Client)
- func (*Hub) Unregister(*Client)
- func (*Hub) Run(context.Context)
- func (*Hub) Stop()
- func (*Hub) Publish(*transport.Envelope)
---
## Package `catalog` (`internal/catalog`)
Package catalog owns the static configuration entities: tracks and
cars. Both are first-class resources that drivers browse, pick, and
(in later phases) author.
PoC: in-memory cache backed by a pluggable Store. The runtime uses
postgres.PgStore so tracks / cars survive restarts. The cache is the
source of truth for read paths; the Store is the source of truth for
durability. Mutations go through the Store, then refresh the cache.
Design constraints come from the user's apartment layout:
- largest room: 4.46 m × 3.19 m (446 × 319 cm)
- car scale: 1/27 (Compact)
- printer: Bambu A1 (220×220×250 mm build volume)
Tracks are sized to fit a 4.5 × 3.2 m bounding box with a safety
margin, which keeps them physically realizable in the apartment.
Cars are sized to a 1/27 touring envelope (~80 × 35 × 25 mm).
### Interfaces
- **interface Store** { Load, UpsertTrack, DeleteTrack, UpsertCar, DeleteCar, UpdateCarStats, GetTrackCalendar, CreateTrackCalendar, DeleteTrackCalendar }
### Structs
- **struct CreateTrackOptions** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline }
- **struct UpdateTrackOptions** { Name, Description, Visibility, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline }
- **struct CreateCarOptions** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, TopSpeedMs, ColorHex, AvatarURL, Active, DeviceID }
- **struct UpdateCarOptions** { Name, Visibility, LengthMm, WidthMm, HeightMm, WeightG, TopSpeedMs, ColorHex, Active, Chassis, Motor, Battery, Drive, DeviceID }
- **struct Waypoint** { X, Y, HeadingRad, SpeedMs, Curvature }
- **struct Bounds** { MinX, MinY, MaxX, MaxY, LengthM, WidthM }
- **struct TrackMeta** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Bounds, Surface, Tags, Centerline, BestLapMs, BestLapHolder, CreatedMs, UpdatedMs }
- **struct TrackCalendarEntry** { ID, TrackID, StartAt, EndAt }
- **struct MotorSpec** { Kind, Class, KV, PowerW }
- **struct BatterySpec** { VoltageV, CapacityMah, Cells, Chemistry }
- **struct ChassisSpec** { Model, Material, Printed, WheelbaseMm, TrackMm }
- **struct CarMeta** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, DeviceID, TopSpeedMs, ColorHex, AvatarURL, Active, TotalDistanceM, TotalRaces, TotalLaps, BestLapMs, CreatedMs, UpdatedMs }
- **struct Snapshot** { GeneratedMs, Version, Tracks, Cars }
- **struct Stats** { TracksTotal, TracksSystem, TracksPublic, TracksPrivate, CarsTotal, CarsSystem, CarsPublic, CarsPrivate, CarsActive }
- **struct Event** { Kind, TrackID, CarID, Snapshot }
- **struct Service**
### Functions
- func NewService(context.Context, Store) (*Service, error)
### Methods
- func (CreateTrackOptions) Validate() error
- func (*Service) CreateTrack(context.Context, CreateTrackOptions) (TrackMeta, error)
- func (*Service) UpdateTrack(context.Context, string, UpdateTrackOptions) (TrackMeta, error)
- func (*Service) DeleteTrack(context.Context, string) error
- func (CreateCarOptions) Validate() error
- func (*Service) CreateCar(context.Context, CreateCarOptions) (CarMeta, error)
- func (*Service) UpdateCar(context.Context, string, UpdateCarOptions) (CarMeta, error)
- func (*Service) DeleteCar(context.Context, string) error
- func (*Service) SetCarStats(context.Context, string, func) error
- func (*Service) ListTracksByVisibility(Visibility) []TrackMeta
- func (*Service) ListCarsByVisibility(Visibility) []CarMeta
- func (*Service) ListCarsByOwner(string) []CarMeta
- func (*Service) GetTrackCalendar(context.Context) ([]TrackCalendarEntry, error)
- func (*Service) CreateTrackCalendar(context.Context, string, time.Time, time.Time) (TrackCalendarEntry, error)
- func (*Service) DeleteTrackCalendar(context.Context, int) error
- func (*memoryStore) Load(context.Context) ([]TrackMeta, []CarMeta, error)
- func (*memoryStore) UpsertTrack(context.Context, TrackMeta) error
- func (*memoryStore) DeleteTrack(context.Context, string) error
- func (*memoryStore) UpsertCar(context.Context, CarMeta) error
- func (*memoryStore) DeleteCar(context.Context, string) error
- func (*memoryStore) UpdateCarStats(context.Context, string, func) error
- func (*memoryStore) GetTrackCalendar(context.Context) ([]TrackCalendarEntry, error)
- func (*memoryStore) CreateTrackCalendar(context.Context, TrackCalendarEntry) (TrackCalendarEntry, error)
- func (*memoryStore) DeleteTrackCalendar(context.Context, int) error
- func (*Service) Subscribe() (?, func)
- func (*Service) Stop()
- func (*Service) Done() ?
- func (*Service) Snapshot() Snapshot
- func (*Service) Stats() Stats
- func (*Service) ListTracks() []TrackMeta
- func (*Service) ListCars() []CarMeta
- func (*Service) GetTrack(string) (TrackMeta, bool)
- func (*Service) GetCar(string) (CarMeta, bool)
---
## Package `config` (`internal/config`)
Package config loads runtime configuration from environment variables.
On startup Load() reads a .env file (if present) from the current
working directory and merges its values into the process environment.
Existing process env vars take precedence over .env so deployments
can override .env without modifying the file.
### Structs
- **struct Config** { HTTPAddr, TickRate, TickInterval, LogLevel, MaxClients, SnapshotRate, DevMode, DatabaseURL, JWTSecret, UDPVideoAddr, ESPCmdPort }
### Functions
- func Load() (*Config, error)
---
## Package `seed` (`internal/races/seed`)
### Structs
- **struct DefaultCounts** { Finished, Live, Plans }
- **struct Options** { Counts, Seed, Reset, Now }
- **struct Summary** { FinishedInserted, LiveCreated, PlansInserted, QueueInserted, Duration }
- **struct Runner**
### Functions
- func NewRunner(*races.PgStore, *lobby.Service, *races.LiveStore, *clans.PgStore, *drivers.PgStore) *Runner
### Methods
- func (*Runner) Run(context.Context, Options) (Summary, error)
---
## Package `stats` (`internal/stats`)
Package stats collects and exposes server and per-driver metrics.
PoC: in-memory only, atomic counters where possible, mutex around
driver and race records. The data lives for the lifetime of the
process. A future storage layer will swap this for Postgres/Redis.
Public surface is split into:
- lifecycle hooks (Connect/Disconnect/Race*/Lap*/Input*/RTT) —
called from cmd/poc-server and from internal/control and lobby
- Snapshot() — full server snapshot for /stats/detailed
- DriverProfile(id) — per-driver summary
- RecentLaps(n) — last N laps across all races
### Structs
- **struct ServerStats** { StartedMs, UptimeMs, CurrentClients, PeakClients, TotalConnections, TotalDisconnects, TotalRacesCreated, TotalRacesStarted, TotalRacesFinished, TotalLapsRecorded, TotalInputsRecv, TotalSnapshotsSent, SnapshotsDropped, AvgRTTMs, RecentRTTs }
- **struct DriverProfile** { ID, Name, TotalRacesStarted, TotalRacesFinished, TotalLaps, BestLapMs, LastLapMs, TotalDistanceM, TotalPlaytimeMs, Wins, Podiums, LastSeenMs }
- **struct LapRecord** { RaceID, DriverID, LapMs, Sector1Ms, Sector2Ms, Sector3Ms, Position, TSMs }
- **struct RaceResult** { RaceID, DriverID, FinishedAtMs, Position, TotalLaps, BestLapMs, TotalTimeMs, DNF }
- **struct Collector**
### Functions
- func NewCollector() *Collector
### Methods
- func (*Collector) Start()
- func (*Collector) OnConnect(string, string)
- func (*Collector) OnDisconnect(string, int64)
- func (*Collector) OnRaceCreated()
- func (*Collector) OnRaceStarted([]string)
- func (*Collector) OnRaceFinished(RaceResult)
- func (*Collector) OnLapRecorded(LapRecord)
- func (*Collector) OnInputProcessed()
- func (*Collector) OnSnapshotSent(uint64, uint64)
- func (*Collector) OnRTT(int64)
- func (*Collector) OnDistance(string, float64)
- func (*Collector) Server() ServerStats
- func (*Collector) Driver(string, string) DriverProfile
- func (*Collector) Drivers() []DriverProfile
- func (*Collector) RecentLaps(int) []LapRecord
- func (*Collector) RecentResults(int) []RaceResult
---
## 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 `transport` (`internal/transport`)
Package transport defines the wire format between clients and the server.
PoC: JSON for fast iteration. Specified by proto/client_server.proto for
the production binary protobuf format.
TODO PoC: replace JSON with protobuf-generated structs (see
../proto/client_server.proto).
### Structs
- **struct Envelope** { Type, Seq, TSMs, Payload }
- **struct ClientHello** { Version, Token, Locale, ClientID }
- **struct ServerHello** { SessionID, RaceTick, ServerVersion, Config }
- **struct JoinRace** { RaceID, CarSlot, CarName }
- **struct LeaveRace** { RaceID }
- **struct InputState** { Steering, Throttle, Brake, Gear, Buttons }
- **struct ChatMessage** { Text }
- **struct Ping** { ClientTSMs }
- **struct RaceSnapshot** { Tick, TSMs, Elapsed, Cars }
- **struct CarInfo** { ID, DriverName, X, Y, Heading, Speed, Lap, Sector, LastLapMs, BestLapMs, InputApplied, DNF }
- **struct InputAck** { Seq, AppliedAtMs, ServerTickMs }
- **struct Correction** { Tick, DeltaSteering, DeltaThrottle, Reason }
- **struct RaceEvent** { RaceID, Event, CarID, Sector, LapMs, TSMs }
- **struct Pong** { ClientTSMs, ServerTSMs }
- **struct ErrorMsg** { Code, Message }
- **struct LobbyRace** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, DriverIDs, Status, CreatedMs, StartedMs, Podium }
- **struct RacePodiumEntry** { Position, DriverID, Name, TotalTimeMs, Nickname, AvatarUrl, ClanID }
- **struct VersionResponse** { Version }
- **struct WebRTCConnectRequest** { SDP, Type, DriverID }
- **struct WebRTCConnectResponse** { SDP, Type }
- **struct LobbyDriver** { ID, Name, Status, RaceID, ConnectedMs, LastSeenMs, Country, AvatarHue }
- **struct LobbySnapshot** { GeneratedMs, Version, Races, Drivers }
- **struct LobbyCreate** { RaceID, Name, TrackID, MaxCars, Laps, TimeLimitS }
- **struct LobbyJoinRace** { RaceID, CarSlot, CarName }
- **struct LobbyLeaveRace** { RaceID }
- **struct LobbyKickDriver** { RaceID, DriverID }
- **struct LobbyDelete** { RaceID }
- **struct LobbyCreatedAck** { Ok, Race, Error, Driver }
- **struct LobbyJoinAck** { Ok, Race, Error }
- **struct TrackWaypoint** { X, Y, HeadingRad, SpeedMs, Curvature }
- **struct TrackBounds** { MinX, MinY, MaxX, MaxY, LengthM, WidthM }
- **struct TrackWire** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Bounds, Surface, Tags, Centerline, BestLapMs, BestLapHolder, CreatedMs, UpdatedMs, IsActive }
- **struct TrackListRequest** { Scope, Tag }
- **struct TrackGetRequest** { ID }
- **struct TrackCreateRequest** { Track }
- **struct TrackUpdateRequest** { ID, Patch }
- **struct TrackPatch** { Name, Description, Visibility, Scale, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline }
- **struct TrackDeleteRequest** { ID }
- **struct TrackSnapshot** { GeneratedMs, Version, Tracks }
- **struct TrackAck** { Ok, ID, Error, Track }
- **struct MotorWire** { Kind, Class, KV, PowerW }
- **struct BatteryWire** { VoltageV, CapacityMah, Cells, Chemistry }
- **struct ChassisWire** { Model, Material, Printed, WheelbaseMm, TrackMm }
- **struct CarWire** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, TopSpeedMs, ColorHex, AvatarURL, Active, TotalDistanceM, TotalRaces, TotalLaps, BestLapMs, CreatedMs, UpdatedMs, DeviceID }
- **struct CarListRequest** { Scope, OwnerID }
- **struct CarGetRequest** { ID }
- **struct CarCreateRequest** { Car }
- **struct CarUpdateRequest** { ID, Patch }
- **struct CarPatch** { Name, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, TopSpeedMs, ColorHex, AvatarURL, Active, DeviceID }
- **struct CarDeleteRequest** { ID }
- **struct CarSnapshot** { GeneratedMs, Version, Cars }
- **struct CarAck** { Ok, ID, Error, Car }
- **struct CatalogSummary** { TracksTotal, TracksSystem, TracksPublic, TracksPrivate, CarsTotal, CarsSystem, CarsPublic, CarsPrivate, CarsActive, TSMs }
- **struct RaceListResponse** { Items, NextCursor, Count }
- **struct RaceUpcomingItem** { Meta, QueueLen, PlanID, StartAtMs }
- **struct RaceUpcomingResponse** { Items, Count }
- **struct RaceQueueEntry** { DriverID, RaceID, PlanID, EnqueuedMs }
- **struct RaceQueueJoinRequest** { DriverID, Limit }
- **struct RaceQueueJoinResponse** { Joined, Skipped }
- **struct RaceQueueListResponse** { Items, Count }
- **struct RacePlan** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, StartAtMs, IntervalS, Count, Enabled, CreatedMs, UpdatedMs, NextFireMs, FiresDone }
- **struct RacePlanCreateRequest** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, StartAtMs, IntervalS, Count, Enabled }
- **struct RacePlanListResponse** { Items, NextCursor, Count }
- **struct Driver** { ID, Nickname, Name, AvatarURL, ClanID, CreatedMs, UpdatedMs }
- **struct DriverCreateRequest** { ID, Nickname, Name, AvatarURL, ClanID }
- **struct DriverUpdateRequest** { Nickname, Name, AvatarURL, ClanID }
- **struct DriverProfileResponse** { Driver, Stats, LastRace, BestRace }
- **struct DriverStats** { TotalRacesStarted, TotalRacesFinished, Wins, Podiums, BestLapMs, TotalPlaytimeMs }
- **struct DriverRaceSummary** { RaceID, RaceName, FinishedMs, Position, TotalTimeMs, BestLapMs, TrackID, TrackName }
- **struct DriverListResponse** { Items, Count }
- **struct Clan** { ID, Tag, Name, AvatarURL, CreatedMs, UpdatedMs }
- **struct ClanCreateRequest** { ID, Tag, Name, AvatarURL }
- **struct ClanUpdateRequest** { Name, AvatarURL }
- **struct ClanListResponse** { Items, Count }
- **struct StatsServer** { StartedMs, UptimeMs, CurrentClients, PeakClients, TotalConnections, TotalDisconnects, TotalRacesCreated, TotalRacesStarted, TotalRacesFinished, TotalLapsRecorded, TotalInputsRecv, TotalSnapshotsSent, SnapshotsDropped, AvgRTTMs }
- **struct StatsDriver** { ID, Name, TotalRacesStarted, TotalRacesFinished, TotalLaps, BestLapMs, LastLapMs, TotalDistanceM, TotalPlaytimeMs, Wins, Podiums, LastSeenMs }
- **struct StatsLap** { RaceID, DriverID, LapMs, Sector1Ms, Sector2Ms, Sector3Ms, Position, TSMs }
- **struct StatsRaceResult** { RaceID, DriverID, FinishedAtMs, Position, TotalLaps, BestLapMs, TotalTimeMs, DNF }
- **struct StatsSnapshot** { Server, Drivers, Laps, Results, TSMs }
- **struct StatsRequest** { Scope, TargetID }
- **struct RaceStanding** { Position, CarID, DriverID, DriverName, Lap, Sector, BestLapMs, LastLapMs, GapMs, DNF }
- **struct RaceStandingsMsg** { RaceID, Tick, TSMs, Lap, Elapsed, Rows }
- **struct LapRecordedMsg** { RaceID, DriverID, Lap, LapMs, Sector1Ms, Sector2Ms, Sector3Ms, TSMs, Position }
- **struct RaceResultMsg** { RaceID, DriverID, Position, TotalLaps, BestLapMs, TotalTimeMs, DNF, FinishedAtMs }
- **struct LeaderboardEntry** { DriverID, Nickname, Name, AvatarURL, ClanID, ClanTag, Points, BestPos, BestTimeMs, Rank }
- **struct LeaderboardResponse** { Items, Total, Limit, Offset, CurrentDriver }
- **struct TrackCalendarEntry** { ID, TrackID, StartAt, EndAt }
- **struct TrackCalendarCreateRequest** { TrackID, StartAt, EndAt }
- **struct TrackCalendarResponse** { Items }
- **struct TrackCalendarLeaderboard** { TrackID, TrackName, StartAt, EndAt, Status, Podium, CurrentDriver }
- **struct TrackCalendarLeaderboardResponse** { Items }
### Functions
- func Encode(*Envelope) ([]byte, error)
- func Decode([]byte) (*Envelope, error)
---
+5
View File
@@ -84,4 +84,9 @@ compose-down:
clean:
rm -rf $(BIN_DIR)
.PHONY: graphify
graphify:
$(GO) run ./scripts/graphify/main.go
.DEFAULT_GOAL := build
+118 -1
View File
@@ -24,9 +24,12 @@ import (
"strconv"
"strings"
"github.com/x0gp/server/internal/auth"
"github.com/x0gp/server/internal/clans"
"github.com/x0gp/server/internal/config"
"github.com/x0gp/server/internal/drivers"
"github.com/x0gp/server/internal/lobby"
"github.com/x0gp/server/internal/races"
"github.com/x0gp/server/internal/transport"
)
@@ -284,13 +287,102 @@ func driversListHandler(svc *drivers.Service) http.HandlerFunc {
// @Router /api/drivers/{id} [put]
// @Router /api/drivers/{id} [delete]
// @Router /api/drivers/{id}/car [put]
func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.HandlerFunc {
func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service, racesSvc *races.Service, cfg *config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/drivers/")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "missing driver id")
return
}
if strings.HasSuffix(id, "/profile") {
driverID := strings.TrimSuffix(id, "/profile")
if driverID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "missing driver id")
return
}
if driverID == "me" {
authedID, ok := auth.DriverIDFromContext(r.Context())
if !ok {
if cfg.DevMode {
driverID = "driver-alice"
} else {
writeError(w, http.StatusUnauthorized, "unauthorized", "missing or invalid auth token")
return
}
} else {
driverID = authedID
}
}
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method)
return
}
drv, err := svc.Get(r.Context(), driverID)
if err != nil {
if errors.Is(err, drivers.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "driver not found")
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
dbStats, dbLast, dbBest, err := racesSvc.GetDriverProfileStats(r.Context(), driverID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
var lastSummary *transport.DriverRaceSummary
if dbLast != nil {
lastSummary = &transport.DriverRaceSummary{
RaceID: dbLast.RaceID,
RaceName: dbLast.RaceName,
FinishedMs: dbLast.FinishedMs,
Position: dbLast.Position,
TotalTimeMs: dbLast.TotalTimeMs,
BestLapMs: dbLast.BestLapMs,
TrackID: dbLast.TrackID,
TrackName: dbLast.TrackName,
}
}
var bestSummary *transport.DriverRaceSummary
if dbBest != nil {
bestSummary = &transport.DriverRaceSummary{
RaceID: dbBest.RaceID,
RaceName: dbBest.RaceName,
FinishedMs: dbBest.FinishedMs,
Position: dbBest.Position,
TotalTimeMs: dbBest.TotalTimeMs,
BestLapMs: dbBest.BestLapMs,
TrackID: dbBest.TrackID,
TrackName: dbBest.TrackName,
}
}
profile := transport.DriverProfileResponse{
Driver: driverToWire(drv),
Stats: transport.DriverStats{
TotalRacesStarted: dbStats.TotalRacesStarted,
TotalRacesFinished: dbStats.TotalRacesFinished,
Wins: dbStats.Wins,
Podiums: dbStats.Podiums,
BestLapMs: dbStats.BestLapMs,
TotalPlaytimeMs: dbStats.TotalPlaytimeMs,
},
LastRace: lastSummary,
BestRace: bestSummary,
}
writeJSON(w, http.StatusOK, profile)
return
}
if strings.HasSuffix(id, "/car") {
driverID := strings.TrimSuffix(id, "/car")
if driverID == "" {
@@ -389,12 +481,21 @@ func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.Hand
}
writeJSON(w, http.StatusOK, driverToWire(d))
case http.MethodPut:
if !cfg.DevMode {
authedID, ok := auth.DriverIDFromContext(r.Context())
if !ok || authedID != id {
writeError(w, http.StatusForbidden, "forbidden", "cannot update another driver's profile")
return
}
}
var req transport.DriverUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
d, err := svc.Update(r.Context(), id, drivers.UpdateInput{
Nickname: req.Nickname,
Name: req.Name,
AvatarURL: req.AvatarURL,
ClanID: req.ClanID,
@@ -404,11 +505,27 @@ func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.Hand
writeError(w, http.StatusNotFound, "not_found", "driver not found")
return
}
if errors.Is(err, drivers.ErrAlreadyExists) {
writeError(w, http.StatusConflict, "conflict", fmt.Sprintf("nickname %s is already taken", req.Nickname))
return
}
if errors.Is(err, drivers.ErrInvalidInput) {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, driverToWire(d))
case http.MethodDelete:
if !cfg.DevMode {
authedID, ok := auth.DriverIDFromContext(r.Context())
if !ok || authedID != id {
writeError(w, http.StatusForbidden, "forbidden", "cannot delete another driver's profile")
return
}
}
if err := svc.Delete(r.Context(), id); err != nil {
if errors.Is(err, drivers.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "driver not found")
+137 -39
View File
@@ -38,6 +38,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
@@ -51,6 +52,7 @@ import (
// the // @... annotations on the handlers.
_ "github.com/x0gp/server/docs"
"github.com/x0gp/server/internal/auth"
"github.com/x0gp/server/internal/catalog"
"github.com/x0gp/server/internal/clans"
"github.com/x0gp/server/internal/config"
@@ -288,33 +290,40 @@ func main() {
// Start WebRTC service
webrtcSvc.Start(ctx, &wg)
apiMux := http.NewServeMux()
apiMux.HandleFunc("/api/version", versionHandler())
apiMux.HandleFunc("/api/catalog", catalogHandler(cat))
apiMux.HandleFunc("/api/tracks", tracksRouter(cat))
apiMux.HandleFunc("/api/tracks/", trackByIDHandler(cat))
apiMux.HandleFunc("/api/tracks/calendar", trackCalendarRouter(cat))
apiMux.HandleFunc("/api/tracks/calendar/", deleteTrackCalendarHandler(cat))
apiMux.HandleFunc("/api/cars", carsRouter(cat))
apiMux.HandleFunc("/api/cars/", carByIDHandler(cat))
apiMux.HandleFunc("/api/races", racesListHandler(racesSvc))
apiMux.HandleFunc("/api/races/last", racesGetLastHandler(racesSvc))
apiMux.HandleFunc("/api/races/", racesGetHandler(racesSvc))
apiMux.HandleFunc("/api/races/upcoming", racesUpcomingHandler(racesSvc))
apiMux.HandleFunc("/api/races/queue", racesQueueRouter(racesSvc))
apiMux.HandleFunc("/api/races/queue/join", racesQueueJoinHandler(racesSvc))
apiMux.HandleFunc("/api/races/plans", racePlansRouter(racesSvc))
apiMux.HandleFunc("/api/races/plans/", racePlansDeleteHandler(racesSvc))
apiMux.HandleFunc("/api/leaderboard", leaderboardHandler(racesSvc))
apiMux.HandleFunc("/api/leaderboard/tracks", calendarLeaderboardHandler(racesSvc))
apiMux.HandleFunc("/api/clans", clansRouter(clansSvc))
apiMux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc))
apiMux.HandleFunc("/api/drivers", driversRouter(driversSvc))
apiMux.HandleFunc("/api/drivers/", driversByIDHandler(driversSvc, lobbySvc, racesSvc, cfg))
apiMux.HandleFunc("/api/video/stream", videoSvc.StreamHandler())
apiMux.HandleFunc("/api/video/control", videoSvc.ControlHandler())
apiMux.HandleFunc("/api/webrtc/connect", webrtcSvc.ConnectHandler())
authMW := auth.Middleware(cfg.JWTSecret, cfg.DevMode)
mux := http.NewServeMux()
mux.HandleFunc("/health", healthHandler(hub, engine))
mux.HandleFunc("/api/version", versionHandler())
mux.HandleFunc("/stats", statsHandler(hub, engine))
mux.HandleFunc("/api/catalog", catalogHandler(cat))
mux.HandleFunc("/api/tracks", tracksRouter(cat))
mux.HandleFunc("/api/tracks/", trackByIDHandler(cat))
mux.HandleFunc("/api/tracks/calendar", trackCalendarRouter(cat))
mux.HandleFunc("/api/tracks/calendar/", deleteTrackCalendarHandler(cat))
mux.HandleFunc("/api/cars", carsRouter(cat))
mux.HandleFunc("/api/cars/", carByIDHandler(cat))
mux.HandleFunc("/api/races", racesListHandler(racesSvc))
mux.HandleFunc("/api/races/upcoming", racesUpcomingHandler(racesSvc))
mux.HandleFunc("/api/races/queue", racesQueueRouter(racesSvc))
mux.HandleFunc("/api/races/queue/join", racesQueueJoinHandler(racesSvc))
mux.HandleFunc("/api/races/plans", racePlansRouter(racesSvc))
mux.HandleFunc("/api/races/plans/", racePlansDeleteHandler(racesSvc))
mux.HandleFunc("/api/leaderboard", leaderboardHandler(racesSvc))
mux.HandleFunc("/api/leaderboard/tracks", calendarLeaderboardHandler(racesSvc))
mux.HandleFunc("/api/clans", clansRouter(clansSvc))
mux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc))
mux.HandleFunc("/api/drivers", driversRouter(driversSvc))
mux.HandleFunc("/api/drivers/", driversByIDHandler(driversSvc, lobbySvc))
mux.HandleFunc("/api/video/stream", videoSvc.StreamHandler())
mux.HandleFunc("/api/video/control", videoSvc.ControlHandler())
mux.HandleFunc("/api/webrtc/connect", webrtcSvc.ConnectHandler())
mux.HandleFunc("/ws", wsHandler(cfg, engine, hub, cat, lobbySvc, driversSvc, videoSvc, logger))
mux.Handle("/api/", authMW(apiMux))
mux.HandleFunc("/ws", wsHandler(cfg, engine, hub, cat, lobbySvc, driversSvc, clansSvc, videoSvc, logger))
// Static client files (resolving TODO)
mux.Handle("/", http.FileServer(http.Dir("./web")))
@@ -483,7 +492,7 @@ func statsHandler(h *Hub, e *control.Engine) http.HandlerFunc {
// @Tags websocket
// @Success 101 {object} transport.Envelope "Switching protocols. Subsequent frames follow the envelope schema."
// @Router /ws [get]
func wsHandler(cfg *config.Config, e *control.Engine, h *Hub, cat *catalog.Service, lobbySvc *lobby.Service, driversSvc *drivers.Service, videoSvc *UDPVideoService, logger *slog.Logger) http.HandlerFunc {
func wsHandler(cfg *config.Config, e *control.Engine, h *Hub, cat *catalog.Service, lobbySvc *lobby.Service, driversSvc *drivers.Service, clansSvc *clans.Service, videoSvc *UDPVideoService, logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
@@ -501,7 +510,7 @@ func wsHandler(cfg *config.Config, e *control.Engine, h *Hub, cat *catalog.Servi
"remote", r.RemoteAddr,
)
go writePump(client, conn, logger)
go readPump(cfg, client, conn, e, h, cat, lobbySvc, driversSvc, videoSvc, logger)
go readPump(cfg, client, conn, e, h, cat, lobbySvc, driversSvc, clansSvc, videoSvc, logger)
}
}
@@ -543,7 +552,7 @@ func writePump(c *realtime.Client, conn *websocket.Conn, logger *slog.Logger) {
}
}
func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *control.Engine, h *Hub, cat *catalog.Service, lobbySvc *lobby.Service, driversSvc *drivers.Service, videoSvc *UDPVideoService, logger *slog.Logger) {
func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *control.Engine, h *Hub, cat *catalog.Service, lobbySvc *lobby.Service, driversSvc *drivers.Service, clansSvc *clans.Service, videoSvc *UDPVideoService, logger *slog.Logger) {
defer func() {
h.Unregister(c)
logger.Info("client disconnected", "client_id", c.ID)
@@ -575,7 +584,7 @@ func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *c
switch env.Type {
case transport.TypeClientHello:
handleClientHello(c, e, cfg, lobbySvc, driversSvc, env)
handleClientHello(c, e, cfg, lobbySvc, driversSvc, clansSvc, env)
case transport.TypeJoinRace:
handleJoinRace(c, e, lobbySvc, cat, env)
case transport.TypeLeaveRace:
@@ -606,7 +615,7 @@ func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *c
// Message handlers --------------------------------------------------------
func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config, lobbySvc *lobby.Service, driversSvc *drivers.Service, env *transport.Envelope) {
func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config, lobbySvc *lobby.Service, driversSvc *drivers.Service, clansSvc *clans.Service, env *transport.Envelope) {
hello := transport.ServerHello{
SessionID: c.ID,
RaceTick: 0,
@@ -617,24 +626,113 @@ func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config
hello.Config.MaxCars = 4
c.SessionID = c.ID
// Extract client_id (DriverID) if present in payload
payload, _ := env.Payload.(map[string]any)
if payload != nil {
if clientID, ok := payload["client_id"].(string); ok && clientID != "" {
var clientID string
var email string
if cfg.DevMode {
// In devMode, client_id is optional and can be arbitrary
if cid, ok := payload["client_id"].(string); ok && cid != "" {
clientID = cid
} else {
clientID = c.ID
}
} else {
// In production, require valid Supabase JWT
tokenStr, _ := payload["token"].(string)
if tokenStr == "" {
sendError(c, "unauthorized", "missing auth token in client_hello")
c.Close()
return
}
claims, err := auth.ValidateJWT(tokenStr, cfg.JWTSecret)
if err != nil {
sendError(c, "unauthorized", fmt.Sprintf("invalid auth token: %v", err))
c.Close()
return
}
clientID = claims.DriverID
email = claims.Email
}
c.DriverID = clientID
// Pre-populate driver in the lobby
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
if drv, err := driversSvc.Get(ctx, clientID); err == nil {
// Resolve/ensure profile and join lobby
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
drv, clanTag, err := ensureDriverProfile(ctx, clientID, email, driversSvc, clansSvc)
if err == nil {
_, _ = lobbySvc.AddDriver(clientID, drv.Name, "")
lobbySvc.SetDriverProfile(clientID, drv.Nickname, drv.AvatarURL, drv.ClanID, "")
}
cancel()
}
lobbySvc.SetDriverProfile(clientID, drv.Nickname, drv.AvatarURL, drv.ClanID, clanTag)
} else {
// Fallback placeholder profile if database error
_, _ = lobbySvc.AddDriver(clientID, "Driver", "")
lobbySvc.SetDriverProfile(clientID, "DRV", "", "", "")
}
c.Send <- mustEncode(transport.TypeServerHello, hello)
}
func ensureDriverProfile(ctx context.Context, id string, email string, driversSvc *drivers.Service, clansSvc *clans.Service) (drivers.Driver, string, error) {
drv, err := driversSvc.Get(ctx, id)
if err == nil {
var clanTag string
if drv.ClanID != "" {
if cl, err := clansSvc.Get(ctx, drv.ClanID); err == nil {
clanTag = cl.Tag
}
}
return drv, clanTag, nil
}
// Profile not found: auto-create a default profile
name := "Driver"
if email != "" {
if idx := strings.Index(email, "@"); idx > 0 {
name = email[:idx]
}
}
// Generate unique 3-letter nickname
nick := "DRV"
if len(name) >= 3 {
candidate := strings.ToUpper(name[:3])
if isAlphaOnly(candidate) {
nick = candidate
}
}
// Check if nickname already exists
for i := 0; i < 20; i++ {
_, err := driversSvc.GetByNickname(ctx, nick)
if err != nil && errors.Is(err, drivers.ErrNotFound) {
break
}
nick = fmt.Sprintf("D%02d", i)
}
created, err := driversSvc.Create(ctx, drivers.CreateInput{
ID: id,
Nickname: nick,
Name: name,
})
if err != nil {
return drivers.Driver{}, "", err
}
return created, "", nil
}
func isAlphaOnly(s string) bool {
for _, c := range s {
if (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') {
return false
}
}
return true
}
func handleJoinRace(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service, cat *catalog.Service, env *transport.Envelope) {
payload, _ := env.Payload.(map[string]any)
slot := 0
+94
View File
@@ -90,6 +90,100 @@ func racesListHandler(svc *races.Service) http.HandlerFunc {
}
}
// racesGetLastHandler godoc
// @Summary Get the results of the last finished race
// @Description Returns the details and podium/results of the most recently finished race.
// @Tags races
// @Produce json
// @Success 200 {object} transport.LobbyRace "Last race results"
// @Failure 404 {object} map[string]interface{} "No finished races found"
// @Failure 500 {object} map[string]interface{} "Internal server error"
// @Router /api/races/last [get]
func racesGetLastHandler(svc *races.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
res, err := svc.GetLastFinished(r.Context())
if err != nil {
if errors.Is(err, races.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "no finished races found")
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
var out transport.LobbyRace
meta := lobby.RaceMeta{
ID: res.ID,
Name: res.Name,
TrackID: res.TrackID,
MaxCars: res.MaxCars,
Laps: res.Laps,
TimeLimitS: res.TimeLimitS,
DriverIDs: res.DriverIDs,
Status: lobby.RaceStatus(res.Status),
CreatedMs: res.CreatedMs,
StartedMs: res.StartedMs,
}
if len(res.Podium) > 0 {
out = lobbyToWireFinished(meta, res.Podium)
} else {
out = lobbyToWire(meta)
}
writeJSON(w, http.StatusOK, out)
}
}
// racesGetHandler godoc
// @Summary Get results of a specific race by ID
// @Description Returns details of a single race by its ID. Works for both live and finished races.
// @Tags races
// @Produce json
// @Param id path string true "Race ID"
// @Success 200 {object} transport.LobbyRace "Race details"
// @Failure 400 {object} map[string]interface{} "Missing race id"
// @Failure 404 {object} map[string]interface{} "Race not found"
// @Failure 500 {object} map[string]interface{} "Internal server error"
// @Router /api/races/{id} [get]
func racesGetHandler(svc *races.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/races/")
if id == "" || strings.Contains(id, "/") {
writeError(w, http.StatusBadRequest, "bad_request", "missing race id")
return
}
res, err := svc.GetRace(r.Context(), id)
if err != nil {
if errors.Is(err, races.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "race not found")
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
var out transport.LobbyRace
meta := lobby.RaceMeta{
ID: res.ID,
Name: res.Name,
TrackID: res.TrackID,
MaxCars: res.MaxCars,
Laps: res.Laps,
TimeLimitS: res.TimeLimitS,
DriverIDs: res.DriverIDs,
Status: lobby.RaceStatus(res.Status),
CreatedMs: res.CreatedMs,
StartedMs: res.StartedMs,
}
if len(res.Podium) > 0 {
out = lobbyToWireFinished(meta, res.Podium)
} else {
out = lobbyToWire(meta)
}
writeJSON(w, http.StatusOK, out)
}
}
func parseRaceListFilter(r *http.Request) (races.ListFilter, error) {
q := r.URL.Query()
cur, err := races.DecodeCursor(q.Get("cursor"))
+88
View File
@@ -1192,6 +1192,40 @@ const docTemplate = `{
}
}
},
"/api/races/last": {
"get": {
"description": "Returns the details and podium/results of the most recently finished race.",
"produces": [
"application/json"
],
"tags": [
"races"
],
"summary": "Get the results of the last finished race",
"responses": {
"200": {
"description": "Last race results",
"schema": {
"$ref": "#/definitions/transport.LobbyRace"
}
},
"404": {
"description": "No finished races found",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/races/plans": {
"get": {
"description": "Returns race plans in ascending start_at order. Pass back the ` + "`" + `next_cursor` + "`" + ` to get the next page.",
@@ -1534,6 +1568,56 @@ const docTemplate = `{
}
}
},
"/api/races/{id}": {
"get": {
"description": "Returns details of a single race by its ID. Works for both live and finished races.",
"produces": [
"application/json"
],
"tags": [
"races"
],
"summary": "Get results of a specific race by ID",
"parameters": [
{
"type": "string",
"description": "Race ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Race details",
"schema": {
"$ref": "#/definitions/transport.LobbyRace"
}
},
"400": {
"description": "Missing race id",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Race not found",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/tracks": {
"get": {
"description": "Returns all tracks visible to the caller. Optional query filters: ` + "`" + `scope=system|public|private` + "`" + `, ` + "`" + `tag=\u003cstring\u003e` + "`" + `.",
@@ -2544,6 +2628,10 @@ const docTemplate = `{
"name": {
"type": "string",
"example": "Alice"
},
"nickname": {
"type": "string",
"example": "ALI"
}
}
},
+88
View File
@@ -1190,6 +1190,40 @@
}
}
},
"/api/races/last": {
"get": {
"description": "Returns the details and podium/results of the most recently finished race.",
"produces": [
"application/json"
],
"tags": [
"races"
],
"summary": "Get the results of the last finished race",
"responses": {
"200": {
"description": "Last race results",
"schema": {
"$ref": "#/definitions/transport.LobbyRace"
}
},
"404": {
"description": "No finished races found",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/races/plans": {
"get": {
"description": "Returns race plans in ascending start_at order. Pass back the `next_cursor` to get the next page.",
@@ -1532,6 +1566,56 @@
}
}
},
"/api/races/{id}": {
"get": {
"description": "Returns details of a single race by its ID. Works for both live and finished races.",
"produces": [
"application/json"
],
"tags": [
"races"
],
"summary": "Get results of a specific race by ID",
"parameters": [
{
"type": "string",
"description": "Race ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Race details",
"schema": {
"$ref": "#/definitions/transport.LobbyRace"
}
},
"400": {
"description": "Missing race id",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Race not found",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/tracks": {
"get": {
"description": "Returns all tracks visible to the caller. Optional query filters: `scope=system|public|private`, `tag=\u003cstring\u003e`.",
@@ -2542,6 +2626,10 @@
"name": {
"type": "string",
"example": "Alice"
},
"nickname": {
"type": "string",
"example": "ALI"
}
}
},
+62
View File
@@ -245,6 +245,9 @@ definitions:
name:
example: Alice
type: string
nickname:
example: ALI
type: string
type: object
transport.Envelope:
properties:
@@ -1660,6 +1663,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`
+1
View File
@@ -15,6 +15,7 @@ require (
github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/spec v0.20.6 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+2
View File
@@ -14,6 +14,8 @@ github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+105
View File
@@ -0,0 +1,105 @@
// Package auth handles Supabase JWT validation and HTTP middleware.
package auth
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
type contextKey int
const (
driverIDKey contextKey = iota
emailKey
)
// Claims represents the parsed Supabase JWT claims we care about.
type Claims struct {
DriverID string
Email string
}
// ValidateJWT parses and validates a Supabase HMAC-SHA256 JWT using the secret.
func ValidateJWT(tokenStr string, secret string) (Claims, error) {
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(secret), nil
})
if err != nil {
return Claims{}, err
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
sub, _ := claims["sub"].(string)
email, _ := claims["email"].(string)
if sub == "" {
return Claims{}, errors.New("missing sub claim")
}
return Claims{
DriverID: sub,
Email: email,
}, nil
}
return Claims{}, errors.New("invalid token")
}
// Middleware returns an HTTP middleware that extracts and validates the Bearer token.
// When devMode is enabled, authentication is bypassed entirely.
func Middleware(secret string, devMode bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if devMode {
next.ServeHTTP(w, r)
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized","message":"missing authorization header"}`))
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized","message":"invalid authorization format"}`))
return
}
claims, err := ValidateJWT(parts[1], secret)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(fmt.Sprintf(`{"error":"unauthorized","message":%q}`, err.Error())))
return
}
ctx := context.WithValue(r.Context(), driverIDKey, claims.DriverID)
ctx = context.WithValue(ctx, emailKey, claims.Email)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// DriverIDFromContext retrieves the driver ID from the context.
func DriverIDFromContext(ctx context.Context) (string, bool) {
id, ok := ctx.Value(driverIDKey).(string)
return id, ok
}
// EmailFromContext retrieves the email from the context.
func EmailFromContext(ctx context.Context) (string, bool) {
email, ok := ctx.Value(emailKey).(string)
return email, ok
}
+125
View File
@@ -0,0 +1,125 @@
package auth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
const testSecret = "my-super-secret-supabase-jwt-key"
func generateTestToken(sub string, email string, expiresAt time.Time, secret string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": sub,
"email": email,
"exp": expiresAt.Unix(),
})
return token.SignedString([]byte(secret))
}
func TestValidateJWT(t *testing.T) {
// 1. Valid token
validToken, err := generateTestToken("driver-uuid-123", "driver@test.com", time.Now().Add(time.Hour), testSecret)
if err != nil {
t.Fatalf("failed to generate valid token: %v", err)
}
claims, err := ValidateJWT(validToken, testSecret)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
if claims.DriverID != "driver-uuid-123" || claims.Email != "driver@test.com" {
t.Errorf("unexpected claims: %+v", claims)
}
// 2. Expired token
expiredToken, err := generateTestToken("driver-uuid-123", "driver@test.com", time.Now().Add(-time.Hour), testSecret)
if err != nil {
t.Fatalf("failed to generate expired token: %v", err)
}
_, err = ValidateJWT(expiredToken, testSecret)
if err == nil {
t.Error("expected error for expired token, got nil")
}
// 3. Invalid signature
_, err = ValidateJWT(validToken, "wrong-secret-key")
if err == nil {
t.Error("expected error for wrong signature, got nil")
}
}
func TestAuthMiddleware(t *testing.T) {
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
driverID, ok := DriverIDFromContext(r.Context())
if ok {
w.Header().Set("X-Driver-ID", driverID)
}
w.WriteHeader(http.StatusOK)
})
// 1. DevMode = true: bypass authentication
mwDev := Middleware(testSecret, true)(nextHandler)
req := httptest.NewRequest(http.MethodGet, "/api/races", nil)
w := httptest.NewRecorder()
mwDev.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 OK in DevMode, got %d", w.Code)
}
// 2. DevMode = false: missing Authorization header
mwProd := Middleware(testSecret, false)(nextHandler)
w = httptest.NewRecorder()
mwProd.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 Unauthorized for missing auth header, got %d", w.Code)
}
// 3. DevMode = false: invalid Authorization format
reqFormat := httptest.NewRequest(http.MethodGet, "/api/races", nil)
reqFormat.Header.Set("Authorization", "InvalidFormat token123")
w = httptest.NewRecorder()
mwProd.ServeHTTP(w, reqFormat)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 Unauthorized for invalid format, got %d", w.Code)
}
// 4. DevMode = false: valid token
validToken, err := generateTestToken("driver-uuid-123", "driver@test.com", time.Now().Add(time.Hour), testSecret)
if err != nil {
t.Fatalf("failed to generate valid token: %v", err)
}
reqValid := httptest.NewRequest(http.MethodGet, "/api/races", nil)
reqValid.Header.Set("Authorization", "Bearer "+validToken)
w = httptest.NewRecorder()
mwProd.ServeHTTP(w, reqValid)
if w.Code != http.StatusOK {
t.Errorf("expected 200 OK for valid token, got %d", w.Code)
}
if w.Header().Get("X-Driver-ID") != "driver-uuid-123" {
t.Errorf("expected X-Driver-ID header to be set in context, got %q", w.Header().Get("X-Driver-ID"))
}
}
func TestContextHelpers(t *testing.T) {
ctx := context.Background()
if _, ok := DriverIDFromContext(ctx); ok {
t.Error("expected ok=false for empty context")
}
ctx = context.WithValue(ctx, driverIDKey, "test-driver")
id, ok := DriverIDFromContext(ctx)
if !ok || id != "test-driver" {
t.Errorf("expected test-driver, got %s (ok=%t)", id, ok)
}
}
+3 -3
View File
@@ -10,14 +10,14 @@ import (
"github.com/jackc/pgx/v5/pgconn"
)
// Service composes validation on top of PgStore.
// Service composes validation on top of Store.
type Service struct {
pg *PgStore
pg Store
now func() time.Time
}
// NewService wires a service.
func NewService(pg *PgStore) *Service {
func NewService(pg Store) *Service {
return &Service{pg: pg, now: time.Now}
}
+166
View File
@@ -0,0 +1,166 @@
package clans
import (
"context"
"errors"
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
type memoryStore struct {
clans map[string]Clan
}
func newMemoryStore() *memoryStore {
return &memoryStore{clans: make(map[string]Clan)}
}
func (m *memoryStore) Exec(ctx context.Context, sql string) (int64, error) {
return 0, nil
}
func (m *memoryStore) Create(ctx context.Context, c Clan) error {
for _, existing := range m.clans {
if existing.Tag == c.Tag {
return &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint"}
}
}
m.clans[c.ID] = c
return nil
}
func (m *memoryStore) Get(ctx context.Context, id string) (Clan, error) {
c, ok := m.clans[id]
if !ok {
return Clan{}, ErrNotFound
}
return c, nil
}
func (m *memoryStore) GetByTag(ctx context.Context, tag string) (Clan, error) {
for _, c := range m.clans {
if c.Tag == tag {
return c, nil
}
}
return Clan{}, ErrNotFound
}
func (m *memoryStore) List(ctx context.Context, limit, offset int) ([]Clan, error) {
out := make([]Clan, 0)
for _, c := range m.clans {
out = append(out, c)
}
return out, nil
}
func (m *memoryStore) Delete(ctx context.Context, id string) error {
if _, ok := m.clans[id]; !ok {
return ErrNotFound
}
delete(m.clans, id)
return nil
}
func (m *memoryStore) Update(ctx context.Context, c Clan) error {
if _, ok := m.clans[c.ID]; !ok {
return ErrNotFound
}
m.clans[c.ID] = c
return nil
}
func (m *memoryStore) Count(ctx context.Context) (int, error) {
return len(m.clans), nil
}
func TestClansService(t *testing.T) {
store := newMemoryStore()
svc := NewService(store)
svc.now = func() time.Time {
return time.Unix(1700000000, 0)
}
ctx := context.Background()
// 1. Create a clan with valid inputs
c1, err := svc.Create(ctx, CreateInput{
ID: "clan-1",
Tag: "SPD",
Name: "Speed Demons",
AvatarURL: "https://example.com/spd.png",
})
if err != nil {
t.Fatalf("Create failed: %v", err)
}
if c1.ID != "clan-1" || c1.Tag != "SPD" || c1.Name != "Speed Demons" {
t.Errorf("Unexpected created clan: %+v", c1)
}
// 2. Validate Tag must be 3 uppercase ASCII letters
_, err = svc.Create(ctx, CreateInput{Tag: "SPEED", Name: "toolongtag"})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("Expected ErrInvalidInput for tag 'SPEED', got: %v", err)
}
_, err = svc.Create(ctx, CreateInput{Tag: "S1D", Name: "withnumbertag"})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("Expected ErrInvalidInput for tag 'S1D', got: %v", err)
}
// 3. Validate empty Name is rejected
_, err = svc.Create(ctx, CreateInput{Tag: "AAA", Name: ""})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("Expected ErrInvalidInput for empty name, got: %v", err)
}
// 4. Duplicate tag check
_, err = svc.Create(ctx, CreateInput{
ID: "clan-2",
Tag: "SPD",
Name: "Duplicate SPD Tag",
})
if !errors.Is(err, ErrAlreadyExists) {
t.Errorf("Expected ErrAlreadyExists for duplicate tag, got: %v", err)
}
// 5. Get clan
got, err := svc.Get(ctx, "clan-1")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if got.Tag != "SPD" {
t.Errorf("Expected SPD, got %s", got.Tag)
}
// 6. Get by tag
gotByTag, err := svc.GetByTag(ctx, "spd") // should normalize to SPD
if err != nil {
t.Fatalf("GetByTag failed: %v", err)
}
if gotByTag.ID != "clan-1" {
t.Errorf("Expected clan-1, got %s", gotByTag.ID)
}
// 7. Update clan
updated, err := svc.Update(ctx, "clan-1", UpdateInput{
Name: "Super Speed Demons",
AvatarURL: "https://example.com/super-spd.png",
})
if err != nil {
t.Fatalf("Update failed: %v", err)
}
if updated.Name != "Super Speed Demons" || updated.AvatarURL != "https://example.com/super-spd.png" {
t.Errorf("Unexpected updated clan fields: %+v", updated)
}
// 8. Delete clan
err = svc.Delete(ctx, "clan-1")
if err != nil {
t.Fatalf("Delete failed: %v", err)
}
_, err = svc.Get(ctx, "clan-1")
if !errors.Is(err, ErrNotFound) {
t.Errorf("Expected ErrNotFound after deletion, got: %v", err)
}
}
+12
View File
@@ -39,6 +39,18 @@ type Clan struct {
UpdatedMs int64
}
// Store defines the persistence operations for clans.
type Store interface {
Exec(ctx context.Context, sql string) (int64, error)
Create(ctx context.Context, c Clan) error
Get(ctx context.Context, id string) (Clan, error)
GetByTag(ctx context.Context, tag string) (Clan, error)
List(ctx context.Context, limit, offset int) ([]Clan, error)
Delete(ctx context.Context, id string) error
Update(ctx context.Context, c Clan) error
Count(ctx context.Context) (int, error)
}
// PgStore is the Postgres-backed half of the package.
type PgStore struct {
pool *pgxpool.Pool
+14
View File
@@ -26,6 +26,7 @@ type Config struct {
DevMode bool // If true, allow multiple clients without auth
DatabaseURL string // Postgres connection string; required at runtime
JWTSecret string // Supabase JWT Secret for signature verification
UDPVideoAddr string // UDP address to listen on for video, e.g. ":9999"
ESPCmdPort int // Port on ESP32 that listens for commands, e.g. 8888
@@ -39,6 +40,14 @@ type Config struct {
func Load() (*Config, error) {
loadDotEnv()
jwtSecret := getEnv("X0GP_JWT_SECRET", "")
if jwtSecret == "" {
jwtSecret = getEnv("JWT_SECRET", "")
}
if jwtSecret == "" {
jwtSecret = getEnv("SUPABASE_JWT_SECRET", "")
}
c := &Config{
HTTPAddr: getEnv("X0GP_HTTP_ADDR", ":8080"),
TickRate: getEnvInt("X0GP_TICK_RATE", 60),
@@ -47,10 +56,15 @@ func Load() (*Config, error) {
SnapshotRate: getEnvInt("X0GP_SNAPSHOT_RATE", 30),
DevMode: getEnvBool("X0GP_DEV_MODE", true),
DatabaseURL: getEnv("DATABASE_URL", ""),
JWTSecret: jwtSecret,
UDPVideoAddr: getEnv("X0GP_UDP_VIDEO_ADDR", ":9999"),
ESPCmdPort: getEnvInt("X0GP_ESP_CMD_PORT", 8888),
}
if !c.DevMode && c.JWTSecret == "" {
return nil, fmt.Errorf("JWT secret (X0GP_JWT_SECRET / JWT_SECRET) is required when DevMode is disabled")
}
if c.TickRate <= 0 || c.TickRate > 240 {
return nil, fmt.Errorf("X0GP_TICK_RATE must be in (0, 240], got %d", c.TickRate)
}
+15 -3
View File
@@ -10,14 +10,14 @@ import (
"github.com/jackc/pgx/v5/pgconn"
)
// Service composes validation on top of PgStore.
// Service composes validation on top of Store.
type Service struct {
pg *PgStore
pg Store
now func() time.Time
}
// NewService wires a service.
func NewService(pg *PgStore) *Service {
func NewService(pg Store) *Service {
return &Service{pg: pg, now: time.Now}
}
@@ -78,6 +78,7 @@ func (s *Service) List(ctx context.Context, limit, offset int, clanID string) ([
// UpdateInput is the patch payload.
type UpdateInput struct {
Nickname string
Name string
AvatarURL string
ClanID *string // nil = leave unchanged, &"" = clear, &"..." = set
@@ -89,6 +90,13 @@ func (s *Service) Update(ctx context.Context, id string, in UpdateInput) (Driver
if err != nil {
return Driver{}, err
}
if in.Nickname != "" {
nick := strings.ToUpper(strings.TrimSpace(in.Nickname))
if !IsValidNickname(nick) {
return Driver{}, fmt.Errorf("%w: nickname must be 3 uppercase ASCII letters", ErrInvalidInput)
}
cur.Nickname = nick
}
if in.Name != "" {
cur.Name = in.Name
}
@@ -99,6 +107,10 @@ func (s *Service) Update(ctx context.Context, id string, in UpdateInput) (Driver
cur.ClanID = *in.ClanID
}
if err := s.pg.Update(ctx, cur); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return Driver{}, fmt.Errorf("%w: %s", ErrAlreadyExists, cur.Nickname)
}
return Driver{}, err
}
return s.pg.Get(ctx, id)
+195
View File
@@ -0,0 +1,195 @@
package drivers
import (
"context"
"errors"
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
type memoryStore struct {
drivers map[string]Driver
}
func newMemoryStore() *memoryStore {
return &memoryStore{drivers: make(map[string]Driver)}
}
func (m *memoryStore) Exec(ctx context.Context, sql string) (int64, error) {
return 0, nil
}
func (m *memoryStore) Create(ctx context.Context, d Driver) error {
for _, existing := range m.drivers {
if existing.Nickname == d.Nickname {
return &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint"}
}
}
m.drivers[d.ID] = d
return nil
}
func (m *memoryStore) Get(ctx context.Context, id string) (Driver, error) {
d, ok := m.drivers[id]
if !ok {
return Driver{}, ErrNotFound
}
return d, nil
}
func (m *memoryStore) GetByNickname(ctx context.Context, nick string) (Driver, error) {
for _, d := range m.drivers {
if d.Nickname == nick {
return d, nil
}
}
return Driver{}, ErrNotFound
}
func (m *memoryStore) List(ctx context.Context, limit, offset int, clanID string) ([]Driver, error) {
out := make([]Driver, 0)
for _, d := range m.drivers {
if clanID == "" || d.ClanID == clanID {
out = append(out, d)
}
}
return out, nil
}
func (m *memoryStore) Delete(ctx context.Context, id string) error {
if _, ok := m.drivers[id]; !ok {
return ErrNotFound
}
delete(m.drivers, id)
return nil
}
func (m *memoryStore) Update(ctx context.Context, d Driver) error {
if _, ok := m.drivers[d.ID]; !ok {
return ErrNotFound
}
for id, existing := range m.drivers {
if id != d.ID && existing.Nickname == d.Nickname {
return &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint"}
}
}
m.drivers[d.ID] = d
return nil
}
func (m *memoryStore) Count(ctx context.Context) (int, error) {
return len(m.drivers), nil
}
func TestDriversService(t *testing.T) {
store := newMemoryStore()
svc := NewService(store)
svc.now = func() time.Time {
return time.Unix(1700000000, 0)
}
ctx := context.Background()
// 1. Create a driver
d1, err := svc.Create(ctx, CreateInput{
ID: "driver-1",
Nickname: "HAM",
Name: "Lewis Hamilton",
AvatarURL: "https://example.com/ham.png",
ClanID: "clan-1",
})
if err != nil {
t.Fatalf("Create failed: %v", err)
}
if d1.ID != "driver-1" || d1.Nickname != "HAM" || d1.Name != "Lewis Hamilton" || d1.ClanID != "clan-1" {
t.Errorf("Unexpected created driver: %+v", d1)
}
// 2. Validate Nickname must be 3 uppercase ASCII letters
_, err = svc.Create(ctx, CreateInput{Nickname: "HAMILTON", Name: "toolong"})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("Expected ErrInvalidInput for tag 'HAMILTON', got: %v", err)
}
// 3. Validate empty Name is rejected
_, err = svc.Create(ctx, CreateInput{Nickname: "BOT", Name: ""})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("Expected ErrInvalidInput for empty name, got: %v", err)
}
// 4. Duplicate nickname check
_, err = svc.Create(ctx, CreateInput{
ID: "driver-2",
Nickname: "HAM",
Name: "Another Lewis",
})
if !errors.Is(err, ErrAlreadyExists) {
t.Errorf("Expected ErrAlreadyExists for duplicate nickname, got: %v", err)
}
// 5. Get driver
got, err := svc.Get(ctx, "driver-1")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if got.Nickname != "HAM" {
t.Errorf("Expected HAM, got %s", got.Nickname)
}
// 6. Get by nickname
gotByNick, err := svc.GetByNickname(ctx, "ham") // should normalize to HAM
if err != nil {
t.Fatalf("GetByNickname failed: %v", err)
}
if gotByNick.ID != "driver-1" {
t.Errorf("Expected driver-1, got %s", gotByNick.ID)
}
// 7. Update driver (including nickname)
clanID := "clan-2"
updated, err := svc.Update(ctx, "driver-1", UpdateInput{
Nickname: "VET",
Name: "Lewis Hamilton MBE",
AvatarURL: "https://example.com/ham-mbe.png",
ClanID: &clanID,
})
if err != nil {
t.Fatalf("Update failed: %v", err)
}
if updated.Nickname != "VET" || updated.Name != "Lewis Hamilton MBE" || updated.AvatarURL != "https://example.com/ham-mbe.png" || updated.ClanID != "clan-2" {
t.Errorf("Unexpected updated driver fields: %+v", updated)
}
// 7a. Update with invalid nickname
_, err = svc.Update(ctx, "driver-1", UpdateInput{Nickname: "TOOLONG"})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("Expected ErrInvalidInput for invalid nickname update, got: %v", err)
}
// 7b. Create driver-2 to test duplicate nickname conflict
_, err = svc.Create(ctx, CreateInput{
ID: "driver-2",
Nickname: "HAM",
Name: "Lewis",
})
if err != nil {
t.Fatalf("Create driver-2 failed: %v", err)
}
// 7c. Update driver-2 to nickname "VET" (should fail, already taken by driver-1)
_, err = svc.Update(ctx, "driver-2", UpdateInput{Nickname: "VET"})
if !errors.Is(err, ErrAlreadyExists) {
t.Errorf("Expected ErrAlreadyExists for duplicate nickname update, got: %v", err)
}
// 8. Delete driver
err = svc.Delete(ctx, "driver-1")
if err != nil {
t.Fatalf("Delete failed: %v", err)
}
_, err = svc.Get(ctx, "driver-1")
if !errors.Is(err, ErrNotFound) {
t.Errorf("Expected ErrNotFound after deletion, got: %v", err)
}
}
+14 -2
View File
@@ -39,6 +39,18 @@ type Driver struct {
UpdatedMs int64
}
// Store defines the persistence operations for drivers.
type Store interface {
Exec(ctx context.Context, sql string) (int64, error)
Create(ctx context.Context, d Driver) error
Get(ctx context.Context, id string) (Driver, error)
GetByNickname(ctx context.Context, nick string) (Driver, error)
List(ctx context.Context, limit, offset int, clanID string) ([]Driver, error)
Delete(ctx context.Context, id string) error
Update(ctx context.Context, d Driver) error
Count(ctx context.Context) (int, error)
}
// PgStore is the Postgres-backed half of the package.
type PgStore struct {
pool *pgxpool.Pool
@@ -153,9 +165,9 @@ func (s *PgStore) Update(ctx context.Context, d Driver) error {
d.UpdatedMs = time.Now().UnixMilli()
tag, err := s.pool.Exec(ctx, `
UPDATE drivers
SET name = $2, avatar_url = $3, clan_id = $4, updated_ms = $5
SET nickname = $2, name = $3, avatar_url = $4, clan_id = $5, updated_ms = $6
WHERE id = $1`,
d.ID, d.Name, d.AvatarURL, nullableClan(d.ClanID), d.UpdatedMs)
d.ID, d.Nickname, d.Name, d.AvatarURL, nullableClan(d.ClanID), d.UpdatedMs)
if err != nil {
return err
}
+209
View File
@@ -308,3 +308,212 @@ 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)
}
}
func TestDriverProfileStats(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL is empty; skipping integration test")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("failed to open pool: %v", err)
}
defer pool.Close()
store := NewPgStore(pool)
// Clean up any test records
_, _ = store.Exec(ctx, `DELETE FROM race_queue WHERE driver_id = 'test-stats-drv'`)
_, _ = store.Exec(ctx, `DELETE FROM race_drivers WHERE driver_id = 'test-stats-drv'`)
_, _ = store.Exec(ctx, `DELETE FROM races WHERE id LIKE 'test-stats-race-%'`)
_, _ = store.Exec(ctx, `DELETE FROM drivers WHERE id = 'test-stats-drv'`)
// 1. Create a driver
_, _ = store.Exec(ctx, `
INSERT INTO drivers (id, nickname, name, avatar_url, created_ms, updated_ms)
VALUES ('test-stats-drv', 'TSD', 'Test Stats Driver', '', 1700000000, 1700000000)`)
// 2. Insert races and race results
// Race 1: finished, TSD took 2nd position, best lap 15000, total time 50000
race1 := FinishedRace{
ID: "test-stats-race-1",
Name: "Race 1",
TrackID: "monaco",
MaxCars: 4,
Laps: 5,
TimeLimitS: 300,
DriverIDs: []string{"test-stats-drv"},
Status: "finished",
CreatedMs: 1700000000,
StartedMs: 1700000500,
FinishedMs: 1700060000,
DurationMs: 59500,
TotalLaps: 5,
TotalDrivers: 2,
Results: []DriverResult{
{
DriverID: "test-stats-drv",
TotalTimeMs: 50000,
BestLapMs: 15000,
Position: ptrInt(2),
},
},
}
if err := store.InsertFinished(ctx, race1); err != nil {
t.Fatalf("InsertFinished 1 failed: %v", err)
}
// Race 2: finished, TSD took 1st position (Win), best lap 14000 (Best Lap), total time 45000
race2 := FinishedRace{
ID: "test-stats-race-2",
Name: "Race 2",
TrackID: "monaco",
MaxCars: 4,
Laps: 5,
TimeLimitS: 300,
DriverIDs: []string{"test-stats-drv"},
Status: "finished",
CreatedMs: 1700100000,
StartedMs: 1700100500,
FinishedMs: 1700160000,
DurationMs: 59500,
TotalLaps: 5,
TotalDrivers: 2,
Results: []DriverResult{
{
DriverID: "test-stats-drv",
TotalTimeMs: 45000,
BestLapMs: 14000,
Position: ptrInt(1),
},
},
}
if err := store.InsertFinished(ctx, race2); err != nil {
t.Fatalf("InsertFinished 2 failed: %v", err)
}
// 3. Query stats
stats, err := store.GetDriverStats(ctx, "test-stats-drv")
if err != nil {
t.Fatalf("GetDriverStats failed: %v", err)
}
if stats.TotalRacesStarted != 2 || stats.TotalRacesFinished != 2 {
t.Errorf("expected 2 races started/finished, got: %+v", stats)
}
if stats.Wins != 1 || stats.Podiums != 2 {
t.Errorf("expected 1 win, 2 podiums, got: %+v", stats)
}
if stats.BestLapMs != 14000 {
t.Errorf("expected best lap 14000, got %d", stats.BestLapMs)
}
if stats.TotalPlaytimeMs != 95000 {
t.Errorf("expected total playtime 95000, got %d", stats.TotalPlaytimeMs)
}
// 4. Query last race (should be Race 2, finished at 1700160000)
last, err := store.GetDriverLastRace(ctx, "test-stats-drv")
if err != nil {
t.Fatalf("GetDriverLastRace failed: %v", err)
}
if last == nil || last.RaceID != "test-stats-race-2" {
t.Errorf("expected last race test-stats-race-2, got: %+v", last)
}
// 5. Query best race (should be Race 2, because position 1 is better than 2)
best, err := store.GetDriverBestRace(ctx, "test-stats-drv")
if err != nil {
t.Fatalf("GetDriverBestRace failed: %v", err)
}
if best == nil || best.RaceID != "test-stats-race-2" {
t.Errorf("expected best race test-stats-race-2, got: %+v", best)
}
}
func ptrInt(v int) *int { return &v }
+20 -20
View File
@@ -38,26 +38,26 @@ var defaultDriverSeeds = []struct {
createdMs int64
updatedMs int64
}{
{"driver-seed-001", "BOR", "Gabriel Bortoleto", "https://static.x0gp.ubu.is1di.ru/public/avatar/gabriel-bortoleto-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584003},
{"driver-seed-002", "HUL", "Nico Hulkenberg", "https://static.x0gp.ubu.is1di.ru/public/avatar/nico-hulkenberg-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584007},
{"driver-seed-003", "ALB", "Alex Albon", "https://static.x0gp.ubu.is1di.ru/public/avatar/alex-albon-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584008},
{"driver-seed-004", "SAI", "Carlos Sainz", "https://static.x0gp.ubu.is1di.ru/public/avatar/carlos-sainz-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584011},
{"driver-seed-005", "BEA", "Oliver Bearman", "https://static.x0gp.ubu.is1di.ru/public/avatar/oliver-bearman-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584012},
{"driver-seed-006", "OCO", "Esteban Ocon", "https://static.x0gp.ubu.is1di.ru/public/avatar/esteban-ocon-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584014},
{"driver-seed-007", "LIN", "Arvid Lindblad", "https://static.x0gp.ubu.is1di.ru/public/avatar/arvid-lindblad-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584018},
{"driver-seed-008", "LAW", "Liam Lawson", "https://static.x0gp.ubu.is1di.ru/public/avatar/liam-lawson-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584018},
{"driver-seed-009", "COL", "Franco Colapinto", "https://static.x0gp.ubu.is1di.ru/public/avatar/franco-colapinto-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584020},
{"driver-seed-010", "GAS", "Pierre Gasly", "https://static.x0gp.ubu.is1di.ru/public/avatar/pierre-gasly-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584023},
{"driver-seed-011", "STR", "Lance Stroll", "https://static.x0gp.ubu.is1di.ru/public/avatar/lance-stroll-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584023},
{"driver-seed-012", "ALO", "Fernando Alonso", "https://static.x0gp.ubu.is1di.ru/public/avatar/fernando-alonso-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584026},
{"driver-seed-013", "ANT", "Kimi Antonelli", "https://static.x0gp.ubu.is1di.ru/public/avatar/kimi-antonelli-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584028},
{"driver-seed-014", "RUS", "George Russel", "https://static.x0gp.ubu.is1di.ru/public/avatar/george-russell-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584028},
{"driver-seed-015", "HAD", "Isack Hadjar", "https://static.x0gp.ubu.is1di.ru/public/avatar/isack-hadjar-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584031},
{"driver-seed-016", "VER", "Max Verstappen", "https://static.x0gp.ubu.is1di.ru/public/avatar/max-verstappen-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584033},
{"driver-seed-017", "HAM", "Lewis Hamilton", "https://static.x0gp.ubu.is1di.ru/public/avatar/lewis-hamilton-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584034},
{"driver-seed-018", "NOR", "Lando Norris", "https://static.x0gp.ubu.is1di.ru/public/avatar/lando-norris-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584036},
{"driver-seed-019", "LEC", "Charles Leclerc", "https://static.x0gp.ubu.is1di.ru/public/avatar/charles-leclerc-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584038},
{"driver-seed-020", "PIA", "Oscar Piastri", "https://static.x0gp.ubu.is1di.ru/public/avatar/oscar-piastri-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584039},
{"driver-seed-001", "BOR", "Gabriel Bortoleto", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/gabriel-bortoleto-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584003},
{"driver-seed-002", "HUL", "Nico Hulkenberg", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/nico-hulkenberg-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584007},
{"driver-seed-003", "ALB", "Alex Albon", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/alex-albon-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584008},
{"driver-seed-004", "SAI", "Carlos Sainz", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/carlos-sainz-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584011},
{"driver-seed-005", "BEA", "Oliver Bearman", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/oliver-bearman-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584012},
{"driver-seed-006", "OCO", "Esteban Ocon", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/esteban-ocon-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584014},
{"driver-seed-007", "LIN", "Arvid Lindblad", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/arvid-lindblad-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584018},
{"driver-seed-008", "LAW", "Liam Lawson", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/liam-lawson-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584018},
{"driver-seed-009", "COL", "Franco Colapinto", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/franco-colapinto-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584020},
{"driver-seed-010", "GAS", "Pierre Gasly", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/pierre-gasly-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584023},
{"driver-seed-011", "STR", "Lance Stroll", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/lance-stroll-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584023},
{"driver-seed-012", "ALO", "Fernando Alonso", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/fernando-alonso-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584026},
{"driver-seed-013", "ANT", "Kimi Antonelli", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/kimi-antonelli-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584028},
{"driver-seed-014", "RUS", "George Russel", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/george-russell-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584028},
{"driver-seed-015", "HAD", "Isack Hadjar", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/isack-hadjar-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584031},
{"driver-seed-016", "VER", "Max Verstappen", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/max-verstappen-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584033},
{"driver-seed-017", "HAM", "Lewis Hamilton", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/lewis-hamilton-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584034},
{"driver-seed-018", "NOR", "Lando Norris", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/lando-norris-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584036},
{"driver-seed-019", "LEC", "Charles Leclerc", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/charles-leclerc-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584038},
{"driver-seed-020", "PIA", "Oscar Piastri", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/oscar-piastri-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584039},
}
// Default clans.
+42
View File
@@ -812,6 +812,48 @@ 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)
}
// GetDriverProfileStats returns the driver stats and summaries of their last and best races.
func (s *Service) GetDriverProfileStats(ctx context.Context, driverID string) (DriverStats, *DriverRaceSummary, *DriverRaceSummary, error) {
stats, err := s.pg.GetDriverStats(ctx, driverID)
if err != nil {
return DriverStats{}, nil, nil, err
}
last, err := s.pg.GetDriverLastRace(ctx, driverID)
if err != nil {
return DriverStats{}, nil, nil, err
}
best, err := s.pg.GetDriverBestRace(ctx, driverID)
if err != nil {
return DriverStats{}, nil, nil, err
}
return stats, last, best, nil
}
// ---------------------------------------------------------------------------
// Scheduler
// ---------------------------------------------------------------------------
+184
View File
@@ -696,6 +696,61 @@ func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error {
return nil
}
// GetFinished fetches a single finished race by id.
func (s *PgStore) GetFinished(ctx context.Context, id string) (FinishedRace, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, name, track_id, max_cars, laps, time_limit_s,
status, created_ms, started_ms, finished_ms, duration_ms,
total_laps, total_drivers
FROM races
WHERE id = $1 AND status IN ('finished', 'cancelled')`, id)
var r FinishedRace
if err := row.Scan(
&r.ID, &r.Name, &r.TrackID,
&r.MaxCars, &r.Laps, &r.TimeLimitS, &r.Status,
&r.CreatedMs, &r.StartedMs, &r.FinishedMs, &r.DurationMs,
&r.TotalLaps, &r.TotalDrivers,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return FinishedRace{}, ErrNotFound
}
return FinishedRace{}, err
}
if err := s.hydrateFinished(ctx, &r); err != nil {
return FinishedRace{}, err
}
return r, nil
}
// GetLastFinished fetches the most recently finished race.
func (s *PgStore) GetLastFinished(ctx context.Context) (FinishedRace, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, name, track_id, max_cars, laps, time_limit_s,
status, created_ms, started_ms, finished_ms, duration_ms,
total_laps, total_drivers
FROM races
WHERE status IN ('finished', 'cancelled')
ORDER BY finished_ms DESC, id DESC
LIMIT 1`)
var r FinishedRace
if err := row.Scan(
&r.ID, &r.Name, &r.TrackID,
&r.MaxCars, &r.Laps, &r.TimeLimitS, &r.Status,
&r.CreatedMs, &r.StartedMs, &r.FinishedMs, &r.DurationMs,
&r.TotalLaps, &r.TotalDrivers,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return FinishedRace{}, ErrNotFound
}
return FinishedRace{}, err
}
if err := s.hydrateFinished(ctx, &r); err != nil {
return FinishedRace{}, err
}
return r, nil
}
func joinAnd(parts []string) string {
out := ""
for i, p := range parts {
@@ -991,3 +1046,132 @@ func (f FinishedRace) ToLobbyMeta() lobby.RaceMeta {
StartedMs: f.StartedMs,
}
}
// DriverStats holds database-calculated stats.
type DriverStats struct {
TotalRacesStarted int64
TotalRacesFinished int64
Wins int64
Podiums int64
BestLapMs int64
TotalPlaytimeMs int64
}
// DriverRaceSummary holds a summary of a single finished race.
type DriverRaceSummary struct {
RaceID string
RaceName string
FinishedMs int64
Position int
TotalTimeMs int64
BestLapMs int64
TrackID string
TrackName string
}
// GetDriverStats aggregates statistics for a given driver from finished races.
func (s *PgStore) GetDriverStats(ctx context.Context, driverID string) (DriverStats, error) {
row := s.pool.QueryRow(ctx, `
SELECT
COUNT(*)::bigint as total_races_started,
COUNT(CASE WHEN rd.position IS NOT NULL OR rd.total_time_ms > 0 THEN 1 END)::bigint as total_races_finished,
COUNT(CASE WHEN rd.position = 1 THEN 1 END)::bigint as wins,
COUNT(CASE WHEN rd.position >= 1 AND rd.position <= 3 THEN 1 END)::bigint as podiums,
COALESCE(MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms END), 0)::bigint as best_lap_ms,
COALESCE(SUM(rd.total_time_ms), 0)::bigint as total_playtime_ms
FROM race_drivers rd
JOIN races r ON rd.race_id = r.id
WHERE rd.driver_id = $1 AND r.status = 'finished'`, driverID)
var stats DriverStats
err := row.Scan(
&stats.TotalRacesStarted,
&stats.TotalRacesFinished,
&stats.Wins,
&stats.Podiums,
&stats.BestLapMs,
&stats.TotalPlaytimeMs,
)
if err != nil {
return DriverStats{}, fmt.Errorf("query driver stats: %w", err)
}
return stats, nil
}
// GetDriverLastRace returns the last completed race for a driver.
func (s *PgStore) GetDriverLastRace(ctx context.Context, driverID string) (*DriverRaceSummary, error) {
row := s.pool.QueryRow(ctx, `
SELECT
rd.race_id,
r.name,
r.finished_ms,
COALESCE(rd.position, 0) as position,
rd.total_time_ms,
rd.best_lap_ms,
r.track_id,
COALESCE(t.name, '') as track_name
FROM race_drivers rd
JOIN races r ON rd.race_id = r.id
LEFT JOIN tracks t ON r.track_id = t.id
WHERE rd.driver_id = $1 AND r.status = 'finished'
ORDER BY r.finished_ms DESC, r.id DESC
LIMIT 1`, driverID)
var sum DriverRaceSummary
err := row.Scan(
&sum.RaceID,
&sum.RaceName,
&sum.FinishedMs,
&sum.Position,
&sum.TotalTimeMs,
&sum.BestLapMs,
&sum.TrackID,
&sum.TrackName,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("query driver last race: %w", err)
}
return &sum, nil
}
// GetDriverBestRace returns the best completed race for a driver (by position, then time).
func (s *PgStore) GetDriverBestRace(ctx context.Context, driverID string) (*DriverRaceSummary, error) {
row := s.pool.QueryRow(ctx, `
SELECT
rd.race_id,
r.name,
r.finished_ms,
COALESCE(rd.position, 0) as position,
rd.total_time_ms,
rd.best_lap_ms,
r.track_id,
COALESCE(t.name, '') as track_name
FROM race_drivers rd
JOIN races r ON rd.race_id = r.id
LEFT JOIN tracks t ON r.track_id = t.id
WHERE rd.driver_id = $1 AND r.status = 'finished' AND rd.position IS NOT NULL AND rd.position > 0
ORDER BY rd.position ASC, rd.total_time_ms ASC, r.finished_ms DESC, r.id DESC
LIMIT 1`, driverID)
var sum DriverRaceSummary
err := row.Scan(
&sum.RaceID,
&sum.RaceName,
&sum.FinishedMs,
&sum.Position,
&sum.TotalTimeMs,
&sum.BestLapMs,
&sum.TrackID,
&sum.TrackName,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("query driver best race: %w", err)
}
return &sum, nil
}
@@ -11,7 +11,7 @@
-- (S2). We enable it here so that the extension is provisioned alongside
-- the base schema.
CREATE EXTENSION IF NOT EXISTS timescaledb;
-- CREATE EXTENSION IF NOT EXISTS timescaledb;
-- ---------------------------------------------------------------------------
-- Tracks
+31
View File
@@ -674,11 +674,42 @@ type DriverCreateRequest struct {
// DriverUpdateRequest is the body of PUT /api/drivers/{id}.
type DriverUpdateRequest struct {
Nickname string `json:"nickname,omitempty" example:"ALI"`
Name string `json:"name,omitempty" example:"Alice"`
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/u/ace.png"`
ClanID *string `json:"clan_id,omitempty" example:"clan-1782000-1"`
}
// DriverProfileResponse is the body returned by GET /api/drivers/{id}/profile.
type DriverProfileResponse struct {
Driver Driver `json:"driver"`
Stats DriverStats `json:"stats"`
LastRace *DriverRaceSummary `json:"last_race"`
BestRace *DriverRaceSummary `json:"best_race"`
}
// DriverStats contains aggregate driver statistics.
type DriverStats struct {
TotalRacesStarted int64 `json:"total_races_started" example:"10"`
TotalRacesFinished int64 `json:"total_races_finished" example:"8"`
Wins int64 `json:"wins" example:"2"`
Podiums int64 `json:"podiums" example:"5"`
BestLapMs int64 `json:"best_lap_ms" example:"12345"`
TotalPlaytimeMs int64 `json:"total_playtime_ms" example:"1200000"`
}
// DriverRaceSummary describes one race result in a driver's profile.
type DriverRaceSummary struct {
RaceID string `json:"race_id" example:"race-123"`
RaceName string `json:"race_name" example:"Monaco Grand Prix"`
FinishedMs int64 `json:"finished_ms" example:"1700000000000"`
Position int `json:"position" example:"1"`
TotalTimeMs int64 `json:"total_time_ms" example:"123456"`
BestLapMs int64 `json:"best_lap_ms" example:"12345"`
TrackID string `json:"track_id" example:"monaco"`
TrackName string `json:"track_name" example:"Monaco"`
}
// DriverListResponse is the body returned by GET /api/drivers.
type DriverListResponse struct {
Items []Driver `json:"items"`
+250
View File
@@ -0,0 +1,250 @@
// 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 main
import (
"bytes"
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"strings"
)
type PackageSummary struct {
Name string
Path string
Doc string
Structs []string
Interfaces []string
Functions []string
Methods []string
}
func main() {
rootDir := "."
outputFile := "CODEBASE.md"
var buf bytes.Buffer
buf.WriteString("# x0gp Go Codebase Map (Generated)\n\n")
buf.WriteString("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.\n\n")
pkgs := make(map[string]*PackageSummary)
fset := token.NewFileSet()
err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
// Skip hidden and build dirs
name := d.Name()
if (name != "." && strings.HasPrefix(name, ".")) || name == "vendor" || name == "bin" || name == "docs" || name == "web" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(d.Name(), ".go") || strings.HasSuffix(d.Name(), "_test.go") {
return nil
}
dir := filepath.Dir(path)
pkgSum, exists := pkgs[dir]
if !exists {
pkgSum = &PackageSummary{
Path: dir,
}
pkgs[dir] = pkgSum
}
node, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil // ignore unparseable files defensively
}
pkgSum.Name = node.Name.Name
if node.Doc != nil && pkgSum.Doc == "" {
pkgSum.Doc = strings.TrimSpace(node.Doc.Text())
}
// Inspect AST for declarations
ast.Inspect(node, func(n ast.Node) bool {
switch decl := n.(type) {
case *ast.GenDecl:
for _, spec := range decl.Specs {
if typeSpec, ok := spec.(*ast.TypeSpec); ok {
if !ast.IsExported(typeSpec.Name.Name) {
continue
}
switch typeType := typeSpec.Type.(type) {
case *ast.StructType:
structDesc := fmt.Sprintf("- **struct %s**", typeSpec.Name.Name)
if typeType.Fields != nil && len(typeType.Fields.List) > 0 {
fields := []string{}
for _, f := range typeType.Fields.List {
for _, id := range f.Names {
if ast.IsExported(id.Name) {
fields = append(fields, id.Name)
}
}
}
if len(fields) > 0 {
structDesc += fmt.Sprintf(" { %s }", strings.Join(fields, ", "))
}
}
pkgSum.Structs = append(pkgSum.Structs, structDesc)
case *ast.InterfaceType:
methods := []string{}
if typeType.Methods != nil {
for _, m := range typeType.Methods.List {
for _, id := range m.Names {
if ast.IsExported(id.Name) {
methods = append(methods, id.Name)
}
}
}
}
pkgSum.Interfaces = append(pkgSum.Interfaces, fmt.Sprintf("- **interface %s** { %s }", typeSpec.Name.Name, strings.Join(methods, ", ")))
}
}
}
case *ast.FuncDecl:
if !ast.IsExported(decl.Name.Name) {
return true
}
// Format function signature
params := []string{}
if decl.Type.Params != nil {
for _, p := range decl.Type.Params.List {
for range p.Names {
params = append(params, formatType(p.Type))
}
if len(p.Names) == 0 {
params = append(params, formatType(p.Type))
}
}
}
results := []string{}
if decl.Type.Results != nil {
for _, r := range decl.Type.Results.List {
for range r.Names {
results = append(results, formatType(r.Type))
}
if len(r.Names) == 0 {
results = append(results, formatType(r.Type))
}
}
}
sig := fmt.Sprintf("%s(%s)", decl.Name.Name, strings.Join(params, ", "))
if len(results) > 0 {
if len(results) == 1 {
sig += " " + results[0]
} else {
sig += " (" + strings.Join(results, ", ") + ")"
}
}
if decl.Recv != nil && len(decl.Recv.List) > 0 {
recvType := formatType(decl.Recv.List[0].Type)
pkgSum.Methods = append(pkgSum.Methods, fmt.Sprintf("- func (%s) %s", recvType, sig))
} else {
pkgSum.Functions = append(pkgSum.Functions, fmt.Sprintf("- func %s", sig))
}
}
return true
})
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error gathering package summaries: %v\n", err)
os.Exit(1)
}
// Write out gathered summaries sorted/grouped by path
// (we can sort keys or print them grouped by internal vs cmd)
paths := []string{}
for p := range pkgs {
paths = append(paths, p)
}
// Simple sort: cmd/ first, then internal/
// We want to present it nicely
for _, path := range paths {
sum := pkgs[path]
cleanPath := filepath.ToSlash(path)
buf.WriteString(fmt.Sprintf("## Package `%s` (`%s`)\n\n", sum.Name, cleanPath))
if sum.Doc != "" {
buf.WriteString(fmt.Sprintf("%s\n\n", sum.Doc))
}
if len(sum.Interfaces) > 0 {
buf.WriteString("### Interfaces\n")
for _, item := range sum.Interfaces {
buf.WriteString(item + "\n")
}
buf.WriteString("\n")
}
if len(sum.Structs) > 0 {
buf.WriteString("### Structs\n")
for _, item := range sum.Structs {
buf.WriteString(item + "\n")
}
buf.WriteString("\n")
}
if len(sum.Functions) > 0 {
buf.WriteString("### Functions\n")
for _, item := range sum.Functions {
buf.WriteString(item + "\n")
}
buf.WriteString("\n")
}
if len(sum.Methods) > 0 {
buf.WriteString("### Methods\n")
for _, item := range sum.Methods {
buf.WriteString(item + "\n")
}
buf.WriteString("\n")
}
buf.WriteString("---\n\n")
}
err = os.WriteFile(outputFile, buf.Bytes(), 0644)
if err != nil {
fmt.Fprintf(os.Stderr, "Error writing CODEBASE.md: %v\n", err)
os.Exit(1)
}
fmt.Printf("Generated %s successfully!\n", outputFile)
}
func formatType(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.Ident:
return t.Name
case *ast.StarExpr:
return "*" + formatType(t.X)
case *ast.SelectorExpr:
return formatType(t.X) + "." + t.Sel.Name
case *ast.ArrayType:
return "[]" + formatType(t.Elt)
case *ast.MapType:
return "map[" + formatType(t.Key) + "]" + formatType(t.Value)
case *ast.InterfaceType:
return "interface{}"
case *ast.FuncType:
return "func"
default:
return "?"
}
}
+8
View File
@@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local
+414
View File
@@ -0,0 +1,414 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "x0gp"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
# Controls whether new tables, views, sequences and functions created in the `public` schema by
# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`)
# without explicit GRANTs. When unset, new entities are NOT auto-exposed, matching the new cloud
# default. Set to `true` to keep the legacy behaviour of auto-exposing new entities; this is
# deprecated and the field is removed on 2026-10-30 once the always-revoked behaviour is permanent.
# auto_expose_new_tables = true
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# Maximum amount of time to wait for health check when starting the local database.
health_timeout = "2m"
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files, directories, or glob patterns that describe your database.
# Supports paths relative to supabase directory: "./schemas/*.sql", "./database".
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
# Uncomment to reject non-secure connections to the database.
# [db.ssl_enforcement]
# enabled = true
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[local_smtp]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
[storage.vector]
enabled = true
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
# external_url = ""
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to auth.external_url.
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
# Configure passkey sign-ins.
# [auth.passkey]
# enabled = false
# Configure WebAuthn relying party settings (required when passkey is enabled).
# [auth.webauthn]
# rp_display_name = "Supabase"
# rp_id = "localhost"
# rp_origins = ["http://127.0.0.1:3000"]
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./templates/password_changed_notification.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ `{{ .Code }}` }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ `{{ .Code }}` }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth callback URL derived from auth.external_url.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
# pg-delta is the schema diff engine for db diff / db pull / db remote commit.
# Set enabled = false to fall back to the legacy migra engine.
[experimental.pgdelta]
enabled = true
# Directory under `supabase/` where declarative files are written.
# declarative_schema_path = "./database"
# JSON string passed through to pg-delta SQL formatting.
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
@@ -0,0 +1,265 @@
-- 20260717000000_schema.sql — Consolidated schema for x0gp catalog, races, and drivers.
-- ---------------------------------------------------------------------------
-- Tracks & Waypoints
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS tracks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
author_id TEXT NOT NULL DEFAULT 'system',
visibility TEXT NOT NULL DEFAULT 'system' CHECK (visibility IN ('system', 'public', 'private')),
scale INT NOT NULL DEFAULT 27,
length_m DOUBLE PRECISION NOT NULL DEFAULT 0,
width_m DOUBLE PRECISION NOT NULL DEFAULT 0,
lane_width_m DOUBLE PRECISION NOT NULL DEFAULT 0.20,
surface TEXT NOT NULL DEFAULT 'carpet' CHECK (surface IN ('carpet', 'tile', 'wood', 'paper', 'mixed')),
best_lap_ms BIGINT NOT NULL DEFAULT 0,
best_lap_holder TEXT NOT NULL DEFAULT '',
bounds_min_x DOUBLE PRECISION NOT NULL DEFAULT 0,
bounds_min_y DOUBLE PRECISION NOT NULL DEFAULT 0,
bounds_max_x DOUBLE PRECISION NOT NULL DEFAULT 0,
bounds_max_y DOUBLE PRECISION NOT NULL DEFAULT 0,
created_ms BIGINT NOT NULL,
updated_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS tracks_visibility_idx ON tracks (visibility);
CREATE INDEX IF NOT EXISTS tracks_author_idx ON tracks (author_id);
CREATE TABLE IF NOT EXISTS track_waypoints (
track_id TEXT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
seq INT NOT NULL,
x DOUBLE PRECISION NOT NULL,
y DOUBLE PRECISION NOT NULL,
heading_rad DOUBLE PRECISION NOT NULL,
speed_ms DOUBLE PRECISION NOT NULL,
curvature DOUBLE PRECISION NOT NULL,
PRIMARY KEY (track_id, seq)
);
CREATE TABLE IF NOT EXISTS track_tags (
track_id TEXT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (track_id, tag)
);
CREATE INDEX IF NOT EXISTS track_tags_tag_idx ON track_tags (tag);
-- ---------------------------------------------------------------------------
-- Cars
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS cars (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
owner_id TEXT NOT NULL DEFAULT 'system',
visibility TEXT NOT NULL DEFAULT 'system' CHECK (visibility IN ('system', 'public', 'private')),
scale INT NOT NULL DEFAULT 27,
length_mm DOUBLE PRECISION NOT NULL,
width_mm DOUBLE PRECISION NOT NULL,
height_mm DOUBLE PRECISION NOT NULL DEFAULT 0,
weight_g DOUBLE PRECISION NOT NULL DEFAULT 0,
chassis_model TEXT NOT NULL DEFAULT '',
chassis_material TEXT NOT NULL DEFAULT '',
chassis_printed BOOLEAN NOT NULL DEFAULT FALSE,
chassis_wheelbase_mm DOUBLE PRECISION NOT NULL DEFAULT 0,
chassis_track_mm DOUBLE PRECISION NOT NULL DEFAULT 0,
motor_kind TEXT NOT NULL DEFAULT 'brushed',
motor_class TEXT NOT NULL DEFAULT '',
motor_kv INT NOT NULL DEFAULT 0,
motor_power_w DOUBLE PRECISION NOT NULL DEFAULT 0,
battery_voltage_v DOUBLE PRECISION NOT NULL DEFAULT 0,
battery_capacity_mah INT NOT NULL DEFAULT 0,
battery_cells INT NOT NULL DEFAULT 0,
battery_chemistry TEXT NOT NULL DEFAULT 'li-po',
drive TEXT NOT NULL DEFAULT '2WD' CHECK (drive IN ('2WD', '4WD')),
top_speed_ms DOUBLE PRECISION NOT NULL DEFAULT 0,
color_hex TEXT NOT NULL DEFAULT '#ff6b1a',
avatar_url TEXT NOT NULL DEFAULT '',
active BOOLEAN NOT NULL DEFAULT TRUE,
total_distance_m DOUBLE PRECISION NOT NULL DEFAULT 0,
total_races INT NOT NULL DEFAULT 0,
total_laps INT NOT NULL DEFAULT 0,
best_lap_ms BIGINT NOT NULL DEFAULT 0,
created_ms BIGINT NOT NULL,
updated_ms BIGINT NOT NULL,
device_id INT
);
CREATE INDEX IF NOT EXISTS cars_visibility_idx ON cars (visibility);
CREATE INDEX IF NOT EXISTS cars_owner_idx ON cars (owner_id);
-- ---------------------------------------------------------------------------
-- Clans & Drivers
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS clans (
id TEXT PRIMARY KEY,
tag TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
avatar_url TEXT NOT NULL DEFAULT '',
created_ms BIGINT NOT NULL,
updated_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS clans_tag_idx ON clans (tag);
CREATE TABLE IF NOT EXISTS drivers (
id TEXT PRIMARY KEY,
nickname TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
avatar_url TEXT NOT NULL DEFAULT '',
clan_id TEXT REFERENCES clans(id) ON DELETE SET NULL,
created_ms BIGINT NOT NULL,
updated_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS drivers_nickname_idx ON drivers (nickname);
CREATE INDEX IF NOT EXISTS drivers_clan_idx ON drivers (clan_id);
-- ---------------------------------------------------------------------------
-- Races & Race Drivers
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS races (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
track_id TEXT NOT NULL DEFAULT '',
max_cars INT NOT NULL,
laps INT NOT NULL,
time_limit_s INT NOT NULL,
status TEXT NOT NULL DEFAULT 'lobby' CHECK (status IN ('lobby', 'countdown', 'racing', 'finished', 'cancelled')),
created_ms BIGINT NOT NULL,
started_ms BIGINT NOT NULL DEFAULT 0,
finished_ms BIGINT NOT NULL DEFAULT 0,
duration_ms BIGINT NOT NULL DEFAULT 0,
total_laps INT NOT NULL DEFAULT 0,
total_drivers INT NOT NULL DEFAULT 0,
updated_ms BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS races_status_finished_idx ON races (status, finished_ms DESC, id DESC);
CREATE INDEX IF NOT EXISTS races_started_idx ON races (status, started_ms DESC, id DESC);
CREATE INDEX IF NOT EXISTS races_track_idx ON races (track_id);
CREATE INDEX IF NOT EXISTS races_status_idx ON races (status);
CREATE TABLE IF NOT EXISTS race_drivers (
race_id TEXT NOT NULL REFERENCES races(id) ON DELETE CASCADE,
driver_id TEXT NOT NULL,
slot INT NOT NULL DEFAULT 0,
joined_ms BIGINT NOT NULL,
total_time_ms BIGINT NOT NULL DEFAULT 0,
best_lap_ms BIGINT NOT NULL DEFAULT 0,
position INT,
PRIMARY KEY (race_id, driver_id)
);
CREATE INDEX IF NOT EXISTS race_drivers_driver_idx ON race_drivers (driver_id);
CREATE INDEX IF NOT EXISTS race_drivers_podium_idx ON race_drivers (race_id, total_time_ms ASC, position ASC) WHERE total_time_ms > 0;
-- ---------------------------------------------------------------------------
-- Race Plans & Queue
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS race_plans (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
track_id TEXT NOT NULL DEFAULT 'default',
max_cars INT NOT NULL,
laps INT NOT NULL DEFAULT 0,
time_limit_s INT NOT NULL DEFAULT 0,
start_at_ms BIGINT NOT NULL,
interval_s INT NOT NULL DEFAULT 0, -- 0 = single-shot
count INT NOT NULL DEFAULT 0, -- 0 = repeat forever
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_ms BIGINT NOT NULL,
updated_ms BIGINT NOT NULL,
next_fire_ms BIGINT NOT NULL, -- last materialised fire time
fires_done INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS race_plans_start_idx ON race_plans (start_at_ms ASC, id ASC);
CREATE INDEX IF NOT EXISTS race_plans_enabled_idx ON race_plans (enabled, next_fire_ms);
CREATE TABLE IF NOT EXISTS race_queue (
driver_id TEXT NOT NULL,
race_id TEXT NOT NULL,
plan_id TEXT NOT NULL DEFAULT '',
enqueued_ms BIGINT NOT NULL,
PRIMARY KEY (driver_id, race_id)
);
CREATE INDEX IF NOT EXISTS race_queue_race_idx ON race_queue (race_id);
CREATE INDEX IF NOT EXISTS race_queue_driver_idx ON race_queue (driver_id, enqueued_ms);
-- ---------------------------------------------------------------------------
-- Lobby Drivers
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS lobby_drivers (
driver_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
nickname TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
clan_id TEXT NOT NULL DEFAULT '',
clan_tag TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'idle' CHECK (status IN ('idle', 'racing', 'offline')),
current_race_id TEXT NOT NULL DEFAULT '',
connected_ms BIGINT NOT NULL,
last_seen_ms BIGINT NOT NULL,
car_id TEXT
);
CREATE INDEX IF NOT EXISTS lobby_drivers_status_idx ON lobby_drivers (status);
-- ---------------------------------------------------------------------------
-- Track Calendar
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS track_calendar (
id SERIAL PRIMARY KEY,
track_id TEXT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
start_at TIMESTAMP WITH TIME ZONE NOT NULL,
end_at TIMESTAMP WITH TIME ZONE NOT NULL,
CHECK (start_at < end_at)
);
CREATE INDEX IF NOT EXISTS track_calendar_dates_idx ON track_calendar (start_at, end_at);
-- ---------------------------------------------------------------------------
-- Go Migration Metadata (Bridges Go server startup migrations and Supabase)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO schema_migrations (version) VALUES
('001_init.sql'),
('002_seed.sql'),
('003_f1_seed.sql'),
('004_monaco_rescale.sql'),
('005_races.sql'),
('006_drop_host.sql'),
('007_podium.sql'),
('008_drivers_clans.sql'),
('009_live_persistence.sql'),
('010_unify_races.sql'),
('011_drop_driver_ids.sql'),
('012_normalize_race_results.sql'),
('013_driver_device.sql'),
('014_track_calendar.sql'),
('015_driver_car_id.sql')
ON CONFLICT (version) DO NOTHING;
+345
View File
@@ -0,0 +1,345 @@
-- seed.sql — Initial system catalog (tracks + cars) for Supabase.
-- ---------------------------------------------------------------------------
-- Monaco (Rescaled)
-- ---------------------------------------------------------------------------
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
created_ms, updated_ms)
VALUES ('monaco', 'Monaco', 'Circuit de Monaco. Narrow street circuit, tight hairpins, no room for error.', 'system', 'system', 27,
21.87, 6.46, 0.18, 'tile', 0, 'Verstappen',
-3.231, -2.684, 3.231, 2.684,
1700000000000, 1700000000000)
ON CONFLICT (id) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 0, -3.2064, 1.0454, -1.4710, 0.632, -0.1988) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 1, -3.1387, 0.5857, -1.3926, 0.632, -0.1787) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 2, -3.0418, 0.1312, -1.3043, 0.632, -0.2544) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 3, -2.8946, -0.3084, -1.1557, 0.632, -0.4431) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 4, -2.6693, -0.7141, -0.8629, 0.632, -1.0875) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 5, -2.3517, -0.9426, -0.1919, 0.632, -1.2844) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 6, -1.8935, -0.8648, 0.1182, 0.632, -0.2237) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 7, -1.4302, -0.8332, 0.0660, 0.632, 0.0475) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 8, -0.9667, -0.8035, 0.0743, 0.632, 0.0756) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 9, -0.5052, -0.7644, -0.0045, 0.632, 0.0806) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 10, -0.0425, -0.8077, -0.0000, 0.632, -0.0425) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 11, 0.4197, -0.7644, 0.0351, 0.632, 0.3650) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 12, 0.8809, -0.7753, -0.3862, 0.632, 1.3887) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 13, 1.1985, -1.0811, -1.2440, 0.632, 1.4550) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 14, 1.1372, -1.5315, -1.5878, 0.632, -0.2144) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 15, 1.1839, -1.9468, -1.0284, 0.632, -1.2681) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 16, 1.5551, -2.2249, -0.6162, 0.632, -0.4763) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 17, 1.9414, -2.4832, -0.5226, 0.632, -0.1756) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 18, 2.3526, -2.6842, -0.4546, 0.632, -0.1494) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 19, 2.3526, -2.6842, 1.2476, 0.632, -0.0225) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 20, 2.4622, -2.3570, 1.2545, 0.632, 0.1775) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 21, 2.5976, -1.9356, 0.5751, 0.632, 34.1925) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 22, 2.6154, -2.2577, -1.0415, 0.632, -0.8400) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 23, 2.9239, -2.4933, 0.0053, 0.632, -3.1975) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 24, 3.2311, -2.2544, 1.4159, 0.632, -2.7456) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 25, 3.0259, -1.8399, 2.1127, 0.632, -0.6488) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 26, 2.7546, -1.4629, 2.2528, 0.632, -0.2769) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 27, 2.4413, -1.1199, 2.3708, 0.632, -0.3044) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 28, 2.0895, -0.8170, 2.5300, 0.632, -0.3825) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 29, 1.6852, -0.5897, 2.7255, 0.632, -0.4019) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 30, 1.2447, -0.4437, 2.9031, 0.632, -0.3538) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 31, 0.7862, -0.3711, 3.0547, 0.632, -0.3044) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 32, 0.3218, -0.3632, -3.0973, 0.632, -0.0975) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 33, -0.1396, -0.4121, -3.1387, 0.632, -0.0394) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 34, -0.5818, -0.3659, -3.0594, 0.632, -0.2375) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 35, -1.0290, -0.4854, -2.9319, 0.632, -0.0950) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 36, -1.4880, -0.5588, -2.9664, 0.632, -0.2500) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 37, -1.9441, -0.6474, 3.1052, 0.632, 0.8825) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 38, -2.3631, -0.5269, 2.4971, 0.632, 1.3600) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 39, -2.6181, -0.1409, 1.9547, 0.632, 0.9481) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 40, -2.7014, 0.3108, 1.5706, 0.632, 0.3788) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 41, -2.6179, 0.7633, 1.6025, 0.632, -0.3425) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 42, -2.7299, 1.2092, 1.8788, 0.632, -0.0725) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 43, -2.8930, 1.6280, 1.6659, 0.632, 0.7706) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 44, -2.8134, 2.0840, 1.2045, 0.632, 0.2162) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 45, -2.5685, 2.4739, 1.5682, 0.632, -2.4475) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 46, -2.8118, 2.6842, -3.0638, 0.632, -4.0544) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 47, -3.1281, 2.4303, -2.0706, 0.632, -1.2500) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 48, -3.2004, 1.9728, -1.6822, 0.632, -0.4706) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 49, -3.2311, 1.5093, -1.5774, 0.632, -0.2275) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 50, -3.2064, 1.0454, -1.4710, 0.632, -0.1988) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'f1') ON CONFLICT DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'street') ON CONFLICT DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'tight') ON CONFLICT DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'slow') ON CONFLICT DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'monaco') ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Barcelona-Catalunya
-- ---------------------------------------------------------------------------
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
created_ms, updated_ms)
VALUES ('barcelona-catalunya', 'Barcelona-Catalunya', 'Circuit de Barcelona-Catalunya. Mix of high-speed corners and technical chicanes.', 'system', 'system', 27,
3.75, 2.30, 0.18, 'tile', 0, 'Verstappen',
0.3, -2.7, 4.05, -0.4,
1700000000000, 1700000000000)
ON CONFLICT (id) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 0, 0.3, -0.4, -0, 4.5, 0) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 1, 1.4, -0.4, -0, 4.5, 0) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 2, 1.7, -0.65, -1.5707963267948966, 2.2, 1.6) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 3, 1.5, -0.95, -3.141592653589793, 2, 2) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 4, 1.2, -1.1, -3.4415926535897934, 2.6, 1) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 5, 0.95, -1, -4.0415926535897935, 2.6, 1) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 6, 0.8, -1.2, 1.5707963267948966, 2.4, 1.2) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 7, 1.05, -1.5, -0, 3.2, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 8, 1.3, -1.65, -1.5707963267948966, 2.4, 1) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 9, 1.15, -1.85, -3.141592653589793, 2.4, 1) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 10, 1.4, -2, -2.641592653589793, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 11, 1.7, -2, -2.141592653589793, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 12, 1.95, -2.2, -1.5707963267948966, 2.8, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 13, 1.65, -2.5, -0.3, 2.8, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 14, 2.2, -2.7, -0.7, 3.2, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 15, 2.8, -2.7, -1.1, 3.4, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 16, 3.1, -2.5, -1.9707963267948967, 2.2, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 17, 2.95, -2.2, -3.541592653589793, 2.2, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 18, 3.3, -1.9, -0, 4, 0) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 19, 4, -1.9, -0, 4, 0) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 20, 4.05, -1.6, 1.5707963267948966, 2.4, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 21, 3.8, -1.4, -3.141592653589793, 2.4, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 22, 3.5, -1.5, -3.9269908169872414, 2.6, 1) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 23, 3.1, -1.25, 1.9707963267948967, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 24, 2.7, -1, 2.4707963267948965, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 25, 2.3, -0.7, -3.541592653589793, 3.4, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 26, 1.6, -0.5, -3.3415926535897933, 3.6, 0.4) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 27, 0.9, -0.42, -0, 4, 0) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('barcelona-catalunya', 'f1') ON CONFLICT DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('barcelona-catalunya', 'balanced') ON CONFLICT DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('barcelona-catalunya', 'testing') ON CONFLICT DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('barcelona-catalunya', 'barcelona') ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Austria (Red Bull Ring)
-- ---------------------------------------------------------------------------
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
created_ms, updated_ms)
VALUES ('austria-redbull-ring', 'Austria (Red Bull Ring)', 'Red Bull Ring. Short, fast, three straights separated by hairpins.', 'system', 'system', 27,
3.55, 2.0, 0.18, 'wood', 0, 'Verstappen',
0.3, -2.4, 3.85, -0.4,
1700000000000, 1700000000000)
ON CONFLICT (id) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 0, 0.3, -0.4, -0, 4.8, 0) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 1, 1.6, -0.4, -0, 4.8, 0) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 2, 1.85, -0.65, -1.5707963267948966, 2, 1.8) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 3, 1.6, -0.95, -3.141592653589793, 2, 1.8) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 4, 1.3, -1, -3.641592653589793, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 5, 1.05, -1.2, 1.5707963267948966, 2.6, 1) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 6, 1.4, -1.4, -0, 3.2, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 7, 1.85, -1.4, -1.7707963267948965, 3.2, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 8, 2.1, -1.65, -3.141592653589793, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 9, 2, -1.85, 1.5707963267948966, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 10, 2.25, -2.05, -0, 2, 1.8) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 11, 2.4, -2, -1.5707963267948966, 2, 1.8) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 12, 2.4, -2.2, -3.141592653589793, 2, 1.8) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 13, 2.2, -2.3, 1.5707963267948966, 2, 1.8) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 14, 2.55, -2.4, 0.2, 3.4, 0.4) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 15, 3, -2.4, -0, 4, 0) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 16, 3.5, -2.4, -0, 4.5, 0) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 17, 3.85, -2.15, -1.7707963267948965, 3.4, 0.6) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 18, 3.6, -1.8, -3.141592653589793, 3, 0.8) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 19, 3.25, -1.85, -3.741592653589793, 3, 0.8) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 20, 3, -1.6, 1.9707963267948967, 3.2, 0.6) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 21, 2.6, -1.4, 3.141592653589793, 3.4, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 22, 2, -1.2, -2.641592653589793, 3.6, 0.4) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 23, 1.4, -0.8, -2.9415926535897934, 3.8, 0.3) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 24, 0.8, -0.5, -0, 4.2, 0.2) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('austria-redbull-ring', 'f1') ON CONFLICT DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('austria-redbull-ring', 'short') ON CONFLICT DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('austria-redbull-ring', 'fast') ON CONFLICT DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('austria-redbull-ring', 'austria') ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Great Britain (Silverstone)
-- ---------------------------------------------------------------------------
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
created_ms, updated_ms)
VALUES ('great-britain-silverstone', 'Great Britain (Silverstone)', 'Silverstone Circuit. Fast, flowing corners; home of British motorsport.', 'system', 'system', 27,
3.55, 2.4, 0.18, 'carpet', 0, 'Hamilton',
0.3, -2.65, 3.85, -0.25,
1700000000000, 1700000000000)
ON CONFLICT (id) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 0, 0.3, -0.3, -0, 5, 0) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 1, 2.4, -0.3, -0, 5, 0) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 2, 2.8, -0.45, -1.5707963267948966, 3, 0.8) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 3, 2.6, -0.8, -3.141592653589793, 3.4, 0.6) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 4, 2.95, -1.05, -2.0707963267948966, 3.2, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 5, 3.3, -1.3, -0, 2.2, 1.6) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 6, 3.6, -1.15, 1.5707963267948966, 2.2, 1.6) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 7, 3.6, -0.8, 3.141592653589793, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 8, 3.85, -0.55, 1.5707963267948966, 2.6, 1) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 9, 3.55, -0.25, -0, 2.4, 1) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 10, 3.25, -0.25, -1.5707963267948966, 2.4, 1) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 11, 3.05, -0.55, -1.8707963267948966, 3.4, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 12, 2.85, -0.45, -3.141592653589793, 4, 0.3) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 13, 1.8, -0.3, -0, 4.6, 0) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 14, 1.2, -0.45, 1.7707963267948965, 3.8, 0.4) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 15, 1, -0.85, 3.541592653589793, 3.6, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 16, 0.7, -1.15, -1.8707963267948966, 2.4, 1.2) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 17, 0.5, -0.9, -0, 2.6, 0.9) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 18, 0.7, -0.65, 1.5707963267948966, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 19, 0.45, -0.45, 3.541592653589793, 2.6, 1) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 20, 0.75, -1, -1.1707963267948966, 2.4, 1) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 21, 0.5, -1.5, 3.541592653589793, 2.6, 0.8) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 22, 0.3, -1.15, 1.5707963267948966, 2.4, 1) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 23, 0.7, -1.6, -0.7853981633974483, 3.4, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 24, 2, -1.95, -1.9707963267948967, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 25, 3, -2.4, -3.3415926535897933, 3.4, 0.4) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 26, 2.4, -2.65, 1.8707963267948966, 4, 0.3) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 27, 1.4, -2.6, 3.141592653589793, 4.4, 0.2) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 28, 0.5, -2.3, 2.8415926535897933, 4.8, 0.2) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 29, 0.3, -1.7, 1.2707963267948965, 5, 0.1) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('great-britain-silverstone', 'f1') ON CONFLICT DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('great-britain-silverstone', 'fast') ON CONFLICT DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('great-britain-silverstone', 'flowing') ON CONFLICT DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('great-britain-silverstone', 'silverstone') ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Belgium (Spa-Francorchamps)
-- ---------------------------------------------------------------------------
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
created_ms, updated_ms)
VALUES ('belgium-spa', 'Belgium (Spa-Francorchamps)', 'Spa-Francorchamps. Long, fast, legendary Eau Rouge / Raidillon.', 'system', 'system', 27,
3.85, 2.25, 0.18, 'carpet', 0, 'Verstappen',
0.4, -2.75, 4.25, -0.5,
1700000000000, 1700000000000)
ON CONFLICT (id) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 0, 0.4, -0.5, -0, 4.8, 0) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 1, 1.6, -0.5, -0, 4.8, 0) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 2, 1.95, -0.6, -0.3, 4.2, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 3, 2.2, -0.95, -0.9, 4, 0.6) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 4, 2.1, -1.3, -3.541592653589793, 4, 0.6) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 5, 2.7, -1.5, -0, 5, 0) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 6, 3.5, -1.5, -0, 5, 0) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 7, 3.85, -1.3, 1.5707963267948966, 2.4, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 8, 3.6, -1.1, -3.4415926535897934, 2.6, 1.2) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 9, 3.3, -1.25, -4.0415926535897935, 2.6, 1.2) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 10, 3.5, -1.55, -1.5707963267948966, 3.6, 0.4) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 11, 3.25, -1.85, -0, 2.2, 1.6) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 12, 3.05, -1.7, 1.5707963267948966, 2.4, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 13, 3.25, -1.5, -0, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 14, 3.7, -1.65, -1.8707963267948966, 3.4, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 15, 4.1, -2, -2.1707963267948966, 4, 0.3) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 16, 4.25, -2.4, -3.541592653589793, 4.6, 0.3) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 17, 4, -2.7, 1.8707963267948966, 2.4, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 18, 3.7, -2.55, -3.141592653589793, 2.4, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 19, 3.55, -2.75, -3.8415926535897933, 2.4, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 20, 3.05, -2.7, 1.5707963267948966, 2, 1.8) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 21, 2.85, -2.45, -0, 2, 1.8) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 22, 2, -2.2, -2.8415926535897933, 3.6, 0.3) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 23, 1.2, -1.6, -2.541592653589793, 4, 0.2) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 24, 0.6, -1.05, -2.8415926535897933, 4.4, 0.2) ON CONFLICT (track_id, seq) DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('belgium-spa', 'f1') ON CONFLICT DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('belgium-spa', 'long') ON CONFLICT DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('belgium-spa', 'fast') ON CONFLICT DO NOTHING;
INSERT INTO track_tags (track_id, tag) VALUES ('belgium-spa', 'spa') ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Cars Catalog
-- ---------------------------------------------------------------------------
INSERT INTO cars (id, name, owner_id, visibility, scale,
length_mm, width_mm, height_mm, weight_g,
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
motor_kind, motor_class, motor_kv, motor_power_w,
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
drive, top_speed_ms, color_hex, avatar_url, active,
created_ms, updated_ms)
VALUES ('f1-2024-redbull-rb20', 'Oracle Red Bull Racing RB20', 'system', 'system', 24,
238, 83, 40, 33,
'RB20 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
'turbo-hybrid', 'F1 PU', 0, 746000,
0, 0, 0, 'li-ion MGU-K',
'2WD', 95, '#1e3a8a', '', TRUE,
1700000000000, 1700000000000)
ON CONFLICT (id) DO NOTHING;
INSERT INTO cars (id, name, owner_id, visibility, scale,
length_mm, width_mm, height_mm, weight_g,
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
motor_kind, motor_class, motor_kv, motor_power_w,
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
drive, top_speed_ms, color_hex, avatar_url, active,
created_ms, updated_ms)
VALUES ('f1-2024-ferrari-sf24', 'Scuderia Ferrari SF-24', 'system', 'system', 24,
238, 83, 40, 33,
'SF-24 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
'turbo-hybrid', 'F1 PU 066/12', 0, 746000,
0, 0, 0, 'li-ion MGU-K',
'2WD', 94, '#dc2626', '', TRUE,
1700000000000, 1700000000000)
ON CONFLICT (id) DO NOTHING;
INSERT INTO cars (id, name, owner_id, visibility, scale,
length_mm, width_mm, height_mm, weight_g,
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
motor_kind, motor_class, motor_kv, motor_power_w,
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
drive, top_speed_ms, color_hex, avatar_url, active,
created_ms, updated_ms)
VALUES ('f1-2024-mclaren-mcl38', 'McLaren MCL38', 'system', 'system', 24,
238, 83, 40, 33,
'MCL38 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
'turbo-hybrid', 'F1 PU Mercedes-derived', 0, 746000,
0, 0, 0, 'li-ion MGU-K',
'2WD', 94, '#ea580c', '', TRUE,
1700000000000, 1700000000000)
ON CONFLICT (id) DO NOTHING;
INSERT INTO cars (id, name, owner_id, visibility, scale,
length_mm, width_mm, height_mm, weight_g,
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
motor_kind, motor_class, motor_kv, motor_power_w,
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
drive, top_speed_ms, color_hex, avatar_url, active,
created_ms, updated_ms)
VALUES ('f1-2024-mercedes-w15', 'Mercedes-AMG W15', 'system', 'system', 24,
238, 83, 40, 33,
'W15 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
'turbo-hybrid', 'F1 PU M15', 0, 746000,
0, 0, 0, 'li-ion MGU-K',
'2WD', 94, '#06b6d4', '', TRUE,
1700000000000, 1700000000000)
ON CONFLICT (id) DO NOTHING;
INSERT INTO cars (id, name, owner_id, visibility, scale,
length_mm, width_mm, height_mm, weight_g,
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
motor_kind, motor_class, motor_kv, motor_power_w,
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
drive, top_speed_ms, color_hex, avatar_url, active,
created_ms, updated_ms)
VALUES ('f1-2024-astonmartin-amr24', 'Aston Martin Aramco AMR24', 'system', 'system', 24,
238, 83, 40, 33,
'AMR24 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
'turbo-hybrid', 'F1 PU Mercedes-derived', 0, 746000,
0, 0, 0, 'li-ion MGU-K',
'2WD', 94, '#16a34a', '', TRUE,
1700000000000, 1700000000000)
ON CONFLICT (id) DO NOTHING;