From 978d36c5054f8ef7b81df9e3c78a641b7d9e1bd7 Mon Sep 17 00:00:00 2001 From: x0gp Date: Mon, 22 Jun 2026 22:00:49 +0400 Subject: [PATCH] feat(server): initial implementation This commit captures the full server code accumulated across several development sessions. It includes the following layers, applied in roughly this order during development: 1. Base infrastructure - cmd/poc-server HTTP+WebSocket server, /health, /stats - internal/transport wire types (Envelope + all message types) - internal/catalog, internal/realtime, internal/stats - internal/storage/postgres with migrations 001-004 - .env, .env.example, docker-compose, Dockerfile, scripts/ 2. Swagger documentation - go get github.com/swaggo/http-swagger - swag init produces docs/docs.go - /swagger/index.html UI, /swagger/doc.json spec - annotations on health/stats/ws and catalog handlers 3. Races API - internal/races/{store,service,keyset,types}.go - migration 005_races.sql: finished_races, race_plans, race_queue - GET /api/races with keyset pagination, status filter - GET /api/races/upcoming - POST /api/races/queue/join, GET, DELETE - POST/GET /api/races/plans, DELETE /{id} - lobby.RaceMeta simplification (no host_id/host_name) 4. Races seeder - internal/races/seed with deterministic generator - --seed-races / --reset CLI flags in main.go - 30 finished + 5 live + 5 plans + 4 queue entries 5. Drivers and clans - internal/drivers, internal/clans (CRUD, validation) - migration 008_drivers_clans.sql - /api/clans and /api/drivers endpoints - 3-letter uppercase nickname/tag validation - lobby.DriverMeta: Nickname, AvatarURL, ClanID, ClanTag 6. Podium - internal/transport.RacePodiumEntry - lobby.SetDriverProfile for in-memory metadata sync - migration 007_podium.sql (podium JSONB column) - seeder populates top-3 per finished race 7. Live races persistence - migration 009_live_persistence.sql: live_races, live_race_drivers, lobby_drivers - internal/races/live_store.go: LiveStore with write-side mirror for lobby.Service mutations - Service.collectLive and Upcoming read from Postgres - main.go RestoreFromDB rehydrates lobby on startup 8. Unify live + finished into one races table - migration 010_unify_races.sql: rename finished_races to races, expand status CHECK, merge live rows - PgStore now hosts both write paths; LiveStore is a thin facade implementing lobby.Persistence - seeder resetAll drops only finished/cancelled rows and race_plans / race_queue / lobby_drivers Each layer is consistent on its own; cross-layer changes are visible in the file history. A future refactor may split this commit into the per-stage boundaries listed above. --- .gitignore | 9 + AGENTS.md | 95 + ARCHITECTURE.md | 223 ++ server/.env.example | 27 + server/.gitignore | 40 + server/Dockerfile | 34 + server/Makefile | 87 + server/PLAN.md | 502 +++ server/README.md | 209 ++ server/cmd/poc-server/catalog_handlers.go | 736 +++++ server/cmd/poc-server/catalog_ws.go | 299 ++ .../cmd/poc-server/drivers_clans_handlers.go | 418 +++ server/cmd/poc-server/main.go | 709 +++++ server/cmd/poc-server/races_handlers.go | 506 +++ server/deploy/prometheus.yml | 16 + server/docker-compose.yml | 90 + server/docs/docs.go | 2734 +++++++++++++++++ server/docs/swagger.json | 2714 ++++++++++++++++ server/docs/swagger.yaml | 1926 ++++++++++++ server/go.mod | 31 + server/go.sum | 100 + server/internal/catalog/catalog.go | 609 ++++ server/internal/catalog/catalog_test.go | 401 +++ server/internal/catalog/seeddefs/seeddefs.go | 614 ++++ .../catalog/seeddefs/seeddefs_test.go | 40 + server/internal/catalog/seeddefs/types.go | 84 + server/internal/catalog/store.go | 30 + server/internal/catalog/store_memory.go | 75 + server/internal/catalog/types.go | 546 ++++ server/internal/clans/service.go | 104 + server/internal/clans/store.go | 171 ++ server/internal/config/config.go | 117 + server/internal/control/race.go | 296 ++ server/internal/drivers/service.go | 110 + server/internal/drivers/store.go | 188 ++ server/internal/lobby/lobby.go | 501 +++ server/internal/lobby/lobby_test.go | 120 + server/internal/lobby/types.go | 293 ++ server/internal/races/keyset.go | 57 + server/internal/races/live_store.go | 75 + server/internal/races/seed/seed.go | 562 ++++ server/internal/races/service.go | 714 +++++ server/internal/races/store.go | 803 +++++ server/internal/races/types.go | 118 + server/internal/realtime/hub.go | 168 + server/internal/stats/stats_test.go | 109 + server/internal/stats/types.go | 363 +++ .../storage/postgres/migrations/001_init.sql | 116 + .../storage/postgres/migrations/002_seed.sql | 561 ++++ .../postgres/migrations/003_f1_seed.sql | 588 ++++ .../migrations/004_monaco_rescale.sql | 75 + .../storage/postgres/migrations/005_races.sql | 90 + .../postgres/migrations/006_drop_host.sql | 12 + .../postgres/migrations/007_podium.sql | 16 + .../postgres/migrations/008_drivers_clans.sql | 37 + .../migrations/009_live_persistence.sql | 68 + .../postgres/migrations/010_unify_races.sql | 65 + server/internal/storage/postgres/pgstore.go | 373 +++ server/internal/storage/postgres/postgres.go | 226 ++ server/internal/transport/codec.go | 836 +++++ server/proto/buf.gen.yaml | 10 + server/proto/buf.yaml | 9 + server/proto/client_server.proto | 145 + server/proto/server_agent.proto | 96 + server/scripts/genseed-json/main.go | 184 ++ server/scripts/genseed/main.go | 200 ++ server/smoke_ws.go | 96 + server/web/README.md | 83 + server/web/index.html | 106 + server/web/main.js | 468 +++ server/web/style.css | 267 ++ 71 files changed, 23500 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 ARCHITECTURE.md create mode 100644 server/.env.example create mode 100644 server/.gitignore create mode 100644 server/Dockerfile create mode 100644 server/Makefile create mode 100644 server/PLAN.md create mode 100644 server/README.md create mode 100644 server/cmd/poc-server/catalog_handlers.go create mode 100644 server/cmd/poc-server/catalog_ws.go create mode 100644 server/cmd/poc-server/drivers_clans_handlers.go create mode 100644 server/cmd/poc-server/main.go create mode 100644 server/cmd/poc-server/races_handlers.go create mode 100644 server/deploy/prometheus.yml create mode 100644 server/docker-compose.yml create mode 100644 server/docs/docs.go create mode 100644 server/docs/swagger.json create mode 100644 server/docs/swagger.yaml create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/internal/catalog/catalog.go create mode 100644 server/internal/catalog/catalog_test.go create mode 100644 server/internal/catalog/seeddefs/seeddefs.go create mode 100644 server/internal/catalog/seeddefs/seeddefs_test.go create mode 100644 server/internal/catalog/seeddefs/types.go create mode 100644 server/internal/catalog/store.go create mode 100644 server/internal/catalog/store_memory.go create mode 100644 server/internal/catalog/types.go create mode 100644 server/internal/clans/service.go create mode 100644 server/internal/clans/store.go create mode 100644 server/internal/config/config.go create mode 100644 server/internal/control/race.go create mode 100644 server/internal/drivers/service.go create mode 100644 server/internal/drivers/store.go create mode 100644 server/internal/lobby/lobby.go create mode 100644 server/internal/lobby/lobby_test.go create mode 100644 server/internal/lobby/types.go create mode 100644 server/internal/races/keyset.go create mode 100644 server/internal/races/live_store.go create mode 100644 server/internal/races/seed/seed.go create mode 100644 server/internal/races/service.go create mode 100644 server/internal/races/store.go create mode 100644 server/internal/races/types.go create mode 100644 server/internal/realtime/hub.go create mode 100644 server/internal/stats/stats_test.go create mode 100644 server/internal/stats/types.go create mode 100644 server/internal/storage/postgres/migrations/001_init.sql create mode 100644 server/internal/storage/postgres/migrations/002_seed.sql create mode 100644 server/internal/storage/postgres/migrations/003_f1_seed.sql create mode 100644 server/internal/storage/postgres/migrations/004_monaco_rescale.sql create mode 100644 server/internal/storage/postgres/migrations/005_races.sql create mode 100644 server/internal/storage/postgres/migrations/006_drop_host.sql create mode 100644 server/internal/storage/postgres/migrations/007_podium.sql create mode 100644 server/internal/storage/postgres/migrations/008_drivers_clans.sql create mode 100644 server/internal/storage/postgres/migrations/009_live_persistence.sql create mode 100644 server/internal/storage/postgres/migrations/010_unify_races.sql create mode 100644 server/internal/storage/postgres/pgstore.go create mode 100644 server/internal/storage/postgres/postgres.go create mode 100644 server/internal/transport/codec.go create mode 100644 server/proto/buf.gen.yaml create mode 100644 server/proto/buf.yaml create mode 100644 server/proto/client_server.proto create mode 100644 server/proto/server_agent.proto create mode 100644 server/scripts/genseed-json/main.go create mode 100644 server/scripts/genseed/main.go create mode 100644 server/smoke_ws.go create mode 100644 server/web/README.md create mode 100644 server/web/index.html create mode 100644 server/web/main.js create mode 100644 server/web/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa792bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.idea/ +.minimax/ +*.csv +server/.out +server/.err +server/poc-server.exe +**/__pycache__/ +*.log +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c8e8eb4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,95 @@ +# AGENTS.md — заметки для ассистента + +Этот файл — заметки, которые нужно помнить между сессиями. + +## MCP-инструменты, которые здесь работают + +### blender-mcp +Управляет Blender-сценой напрямую. Используй когда нужно менять/осматривать 3D-модель машинки (OpenRC_F1_2018_Halo, BodyCam, LayoutCam, Battery и др.). + +Типичные задачи: +- Осмотреть сцену: `blender-mcp_get_scene_info`, `blender-mcp_get_viewport_screenshot` +- Менять геометрию: `blender-mcp_execute_blender_code` (Python в Blender) +- Скачивать ассеты: `blender-mcp_download_polyhaven_asset`, `blender-mcp_search_sketchfab_models`, `blender-mcp_download_sketchfab_model`, `blender-mcp_set_texture` +- Генерировать 3D по тексту/картинке: `blender-mcp_generate_hyper3d_model_via_text`, `blender-mcp_generate_hyper3d_model_via_images`, `blender-mcp_generate_hunyuan3d_model` + +В текущей сцене 78 объектов, 49 материалов. Корневая папка модели — `D:/x0gp`. Blender-сцену открывать не надо — MCP подключён к запущенному Blender. + +### chrome-mcp-server +Управляет Chrome-браузером. Удобно для проверки ссылок на товары из `CART_MVP.md` (Ozon/WB/AliExpress). + +Типичные задачи: +- Список открытых вкладок: `chrome-mcp-server_get_windows_and_tabs` +- Открыть URL: `chrome-mcp_navigate` +- Прочитать страницу: `chrome-mcp_read_page` (предпочтительнее скриншота), `chrome-mcp_get_web_content` +- Клик/ввод: `chrome-mcp_click_element`, `chrome-mcp_fill_or_select`, `chrome-mcp_keyboard` +- Переключить вкладку: `chrome-mcp_switch_tab` +- Скриншот: `chrome-mcp_computer` с `action=screenshot` или `chrome-mcp_screenshot` + +> Сейчас уже открыто ~20 вкладок с товарами из корзины MVP. Не закрывай их без причины. + +## Ключевые файлы проекта + +- `D:/x0gp/hardware/CART_MVP.md` — корзина MVP (что заказать) +- `D:/x0gp/hardware/ASSEMBLY.md` — пошаговая сборка +- `D:/x0gp/hardware/BOM_v4.json` — структурированный BOM (машины в JSON) +- `D:/x0gp/hardware/SHOPPING_LIST.md` — каталог альтернатив (бонусы) +- `D:/x0gp/hardware/PLAN.md` — общий план +- `D:/x0gp/hardware/print/stl/*.stl` — STL для печати на P1S (13 шт) +- `D:/x0gp/hardware/print/parts_manifest.md` — манифест печати +- `D:/x0gp/ARCHITECTURE.md` — общая архитектура проекта + +## Сервер x0gp (`D:/x0gp/server`) + +- Go 1.26, бинарь `cmd/poc-server`. Сборка: `go build ./...` или `go build -o poc-server.exe ./cmd/poc-server`. +- HTTP+WebSocket, роутер `http.NewServeMux`. Запуск требует `DATABASE_URL` (см. `.env`). +- **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/`. +- Не использовать `--parseDependency`: падает на stdlib (`runtime/mprof.go`). + +### Хранилище гонок (гибрид) +- **Live** (status=lobby|countdown|racing): in-memory `lobby.Service`. +- **Finished**: Postgres (`finished_races`), снапшот пишется в `lobby.SetRaceLifecycleHook` при `SetRaceStatus(finished)`. +- **Race plans** (`race_plans`): расписание, материализуется в lobby шедулером раз в 5 сек. +- **Queue** (`race_queue`): подписки `(driver_id, race_id)`. + +### 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/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=...` — посмотреть. +- `DELETE /api/races/queue?driver_id=...&race_id=...` — выйти. +- `POST /api/races/plans` — создать расписание (`start_at_ms, interval_s, count, ...`). +- `GET /api/races/plans` — список планов (keyset `start_at_ms ASC, id ASC`). +- `DELETE /api/races/plans/{id}` — удалить. + +### Сущности drivers и clans +- Таблицы `drivers` (id, nickname [3 uppercase letters], name, avatar_url, clan_id?) и `clans` (id, tag [3 uppercase letters], name, avatar_url). +- 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 = оставить, "" = очистить, иначе = назначить). +- Сидер (через `--seed-races`) заполняет 3 клана и 8 водителей, треки берутся из `tracks` каталога (никаких выдуманных id в `finished_races`/`race_plans`). +- Podium в `LobbyRace` заполняется только для finished-гонок (top-3: position, driver_id, name, total_time_ms). + +### TODO +- Реальная сущность driver ещё не используется для **lobby.DriverMeta** при входе через WS — там пока захардкоженный driver. При первой сессии надо подтягивать профиль из таблицы `drivers` и звать `lobby.SetDriverProfile(id, nick, avatar, clanID, clanTag)`. + +### Сид моковых гонок +- `poc-server.exe --seed-races` — при старте наполняет БД и lobby детерминированными моками: 30 finished, 5 live, 5 plans, 4 queue-entries. +- `poc-server.exe --seed-races --reset` — сначала `TRUNCATE finished_races, race_plans, race_queue` и очищает lobby. +- Без флага `--seed-races` сидер не запускается, даже если `X0GP_SEED_RACES=1` (флаг > env). +- Драйвер — константный, водители в lobby под именами `driver-alice` … `driver-heidi`. + +## Важные факты о машинке (x0gp F1 1/27) + +- Масштаб: 1/27, wheelbase 102мм, deck 80×135мм, толщина 1.5мм PETG +- Мотор MVP: коллекторный 370 35T (Ø24×30мм) с Ozon +- ⚠️ `motor_mount.stl` сейчас под мотор 130/180 (Ø20мм) — надо перепечатать под Ø24мм до сборки +- Кузов печатаем сами (PETG, body_shell.stl), готовый Kyosho НЕ покупаем +- Подшипники: 684ZZ 4×9×4 (перед) + 693ZZ 3×8×4 (зад) +- ESP32-S3-DevKitC-1 N16R8 + U.FL (Wi-Fi агент, не RC-приёмник!) +- MPU-6050 пины: SDA=GPIO21, SCL=GPIO22 (это уже исправлено в ASSEMBLY.md) +- FPV: AIO модуль с WB (1997₽) + антенна RUSHFPV с AliExpress (1050₽) — это **разные вещи** (передатчик vs антенна к нему) +- VRX (приёмник видео) для MVP не нужен — показываем картинку через Wi-Fi стрим ESP32 на телефон/PC diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..9719753 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,223 @@ +# x0gp — Architecture Overview + +Корень проекта. Содержит общую картину системы, glossary и связи между +подсистемами. + +Дата фиксации архитектурных решений: 2026-06-18. +Дата обновления (Bambu A1, своё шасси, домашний сервер): 2026-06-18. +Дата обновления v2 (Orange Pi 5 Pro + Coolify, Compact 1/27): 2026-06-18. + +--- + +## 1. Что такое x0gp + +x0gp — это платформа для проведения **гонок на реальных радиоуправляемых +машинках** через интернет. Игроки подключаются через Web (позже — мобильное +приложение), платят за заезд/турнир и управляют физической машинкой в +реальном времени. + +Целевой пользователь: гонщик-любитель 16-45 лет, желающий участвовать в +реальных гонках без необходимости ехать в хобби-клуб. + +--- + +## 2. Целевые метрики + +| Метрика | Цель | +|---|---| +| Round-trip команды (экран -> колёса) p50 | < 100 мс | +| Round-trip команды p99 | < 200 мс | +| Tick rate гонки | 60 Hz | +| Одновременных активных заездов на Edge-инстанс | 50-100 | +| Доступность | 99.5% (PoC) -> 99.9% (prod) | +| Конверсия "регистрация -> первый заезд" | > 30% | +| Стоимость инфраструктуры на 1 заезд/час | < 5 руб. (цель) | + +--- + +## 3. Компоненты системы + +### 3.1. Слой клиента + +- **Web client** — React, Vite, TypeScript. WebSocket-клиент с + client-side prediction. Canvas/WebGL-рендер трассы и машинок. +- **Mobile client** (фаза 5) — React Native, общий с Web протокол. +- **Admin panel** — React + TanStack Table, управление трассами, турнирами, + пользователями. + +### 3.2. Edge-сервер (Go) + +- **api-gateway** — stateless, REST + WS-терминация, auth, rate-limit. +- **race-engine** — stateful, authoritative tick 60 Hz, fan-out. +- **agent-linker** — long-lived WS к бортовым агентам в помещении. +- **indexer** — пайплайн телеметрии в TimescaleDB. +- **replay-recorder** — пишет replay-чанки в S3. + +### 3.3. Локальная инфраструктура в помещении + +- **Gateway-агент (мини-ПК)** — Go-бинарь, держит связь с Edge-сервером + и машинками, выполняет CV-инференс, буферизует команды на случай обрыва WAN. +- **Wi-Fi роутер** — выделенный SSID "x0gp-track", WPA3. +- **Overhead-камеры** — 1-2 шт, 1080p 60 fps global shutter. +- **IR-магнитные петли** — на секторах трассы. + +### 3.4. Борт машинки + +- **ESP32-S3 агент** — Wi-Fi, WebSocket к gateway, генерация команд ESC/серво. +- **Brushless ESC + мотор** — 1/18 scale. +- **Mini digital servo** — рулевое. +- **IMU, IR/магнитные сенсоры** — телеметрия. + +### 3.5. Хранение + +- **PostgreSQL** — пользователи, платежи, гонки, турниры. +- **Redis** — сессии, pub/sub, лобби, rate-limit. +- **TimescaleDB** — телеметрия, replay-индекс. +- **S3-совместимое** — replay-чанки (видео, JSON-логи). + +### 3.6. Observability + +- **Loki** — логи. +- **Prometheus + Grafana** — метрики. +- **Tempo / Jaeger** — трейсы. +- **Sentry** — ошибки на клиенте и сервере. + +--- + +## 4. Поток данных (упрощённый) + +``` +1. Игрок нажимает газ в браузере + | +2. Web-client отправляет InputState{seq, tsMs, throttle: 1.0} по WS + | +3. api-gateway принимает, валидирует, кладёт в Redis Stream "race:{id}:inputs" + | +4. race-engine на ближайшем тике читает stream, применяет к state + | +5. race-engine публикует RaceSnapshot в Redis pub/sub + | +6. race-engine шлёт AgentCommand в agent-linker (WebSocket в помещение) + | +7. agent-linker шлёт AgentCommand по WS на ESP32-S3 машинки + | +8. ESP32-S3 выдаёт PWM на ESC и серво + | +9. ESP32-S3 шлёт AgentTelemetry обратно (10-30 Hz) + | +10. gateway-агент делает CV-инференс и шлёт CarFrame на сервер + | +11. race-engine обновляет authoritative state, шлёт следующий Snapshot + | +12. Web-client получает Snapshot, корректирует prediction, рисует кадр +``` + +Типичный round-trip: 50-120 мс (зависит от расстояния до Edge-сервера). + +--- + +## 5. Модель доверия + +**Authoritative server**: истиной владеет сервер. Клиент может рендерить +раньше (через client-side prediction), но любое решение о кругах, победах, +позициях принимается сервером на основе данных от agent-linker (телеметрия +ESP32 + CV). + +**Anti-cheat контур**: вход -> sanity check -> race-engine (state) -> +agent-linker (CV) -> indexer (telemetry) -> admin (manual review). + +--- + +## 6. Структура репозитория + +``` +x0gp/ + ARCHITECTURE.md # этот файл + README.md # quickstart, how to run + server/ # Go-код (см. server/PLAN.md) + PLAN.md + cmd/ + internal/ + proto/ + migrations/ + deploy/ + hardware/ # CAD, спецификации, BOM (см. hardware/PLAN.md) + PLAN.md + cad/ + bom/ + photos/ + web/ # React-клиент (отдельный пакет) + mobile/ # React Native (фаза 5) + ops/ # общие devops-скрипты, конфиги + docs/ # ADR, RFC, диаграммы + adr/ + diagrams/ +``` + +--- + +## 7. Glossary + +| Термин | Значение | +|---|---| +| **Race** | Одна гонка: 2-12 игроков на трассе, общий старт, финиш по N кругов или по времени | +| **Lobby** | Пре-гонка: игроки подключаются, выбирают машину, ждут старта | +| **Tick** | 1 шаг симуляции (16.67 мс, 60 Hz) | +| **Snapshot** | Полное состояние гонки на момент тика (позиции, скорости, круги) | +| **Prediction** | Локальная симуляция у клиента между Snapshot'ами | +| **Correction** | Сообщение сервера о расхождении клиентского prediction | +| **Agent** | Софт на борту машинки (ESP32-S3) или в помещении (мини-ПК) | +| **Edge** | Сервер в регионе, ближайшем к игрокам и помещению с трассой | +| **Gateway-агент** | Локальный мини-ПК, "мост" между сервером и машинками в помещении | +| **Track** | Физическая трасса + логическая (layout_id) | +| **Class** | Класс машинки (1/18 Formula, 1/20 Mini и т.д.) | +| **Sector** | Часть круга (1/3, 2/3) — IR-магнитная метка | +| **DNF** | Did Not Finish — игрок не закончил гонку | +| **DQF** | Disqualified — дисквалификация за нарушение | +| **Replay** | Запись гонки для просмотра и разбора | + +--- + +## 8. Ключевые ADR (Architecture Decision Records) + +Подробности — в `docs/adr/`. Здесь — сводка: + +- **ADR-001**: WebSocket + protobuf как основной транспорт. +- **ADR-002**: Authoritative server + client-side prediction. +- **ADR-003**: ESP32-S3 как бортовая MCU, MicroPython в PoC -> ESP-IDF в проде. +- **ADR-004**: Гибрид IR-магнитные секторы + overhead-CV для позиционирования. +- **ADR-005**: Масштаб машинки 1/18 (длина шасси 18-22 см), ширина полосы 30-40 см. +- **ADR-006**: Edge + Gateway-агент: сервер не знает о Wi-Fi трассе, агент буферизует обрыв WAN. +- **ADR-007**: Биллинг через ЮKassa (РФ) + Stripe (международный). +- **ADR-008**: 3D-принтер **Bambu A1** как основной для производства шасси, + барьеров, маунтов, кузовов. Альтернатива — Bambu P1S при необходимости + enclosure для инженерных пластиков. +- **ADR-009**: Шасси машинки **полностью своё**, спроектированное и + напечатанное на 3D-принтере. Не используем покупные донорские деки. +- **ADR-010**: Edge-сервер в PoC и MVP работает на **домашнем сервере** с + доступом из интернета через Cloudflare Tunnel (default) или WireGuard. + Перенос на облако/VPS — только при появлении коммерческой нагрузки и + SLA обязательств. +- **ADR-011**: Домашний сервер — **Orange Pi 5 Pro** (RK3588S, ARM64, 8 cores, + 6 TOPS NPU, 2×2.5 GbE) с развёрнутым **Coolify** (self-hosted PaaS) для + деплоя и управления сервисами. NPU Orange Pi используется для CV-инференса + (YOLO/ByteTrack через RKNN Toolkit 2). LAN-порт Orange Pi изолирует сеть + трассы от домашней сети. Доступ из интернета — Caddy + Let's Encrypt + напрямую (статический IP от провайдера). +- **ADR-012**: Масштаб машинки — **Compact 1/27** как primary класс + (длина шасси 13-15 см, wheelbase 100-115 мм, вес 150-220 г). Ширина полосы + трассы 20-25 см, рабочая зона трассы 200×130 см (помещается в комнату + 446×319 см с запасом). Альтернативный класс — Standard 1/24 (16-18 см) + для тех, кому нужна чуть большая стабильность. Цель — минимизация площади + трассы и стоимости при сохранении управляемости 3D-печати. + +--- + +## 9. Что дальше + +После фиксации плана — переход к **PoC**: +1. Минимальный набор: 1 машинка, 1 трасса 100x60 см, 1 gateway-агент, 1 сервер. +2. Цель: end-to-end "команда -> колёса крутятся, телеметрия возвращается". +3. Срок: 1-2 недели. + +Детали — в `server/PLAN.md` (фаза S0) и `hardware/PLAN.md` (фаза H0). diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..f48a8e4 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,27 @@ +# Copy this file to `.env` and fill in real values. The server reads +# `.env` from the current working directory on startup; missing file +# is fine (production sets vars via the process env / orchestrator). +# +# DATABASE_URL is required. Format: +# postgres://USER:PASSWORD@HOST:PORT/DBNAME?sslmode=disable +# +# Examples: +# 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 + +# HTTP server. +X0GP_HTTP_ADDR=:8080 +X0GP_LOG_LEVEL=info + +# Race engine. +X0GP_TICK_RATE=60 +X0GP_SNAPSHOT_RATE=30 +X0GP_MAX_CLIENTS=100 + +# 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 \ No newline at end of file diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..a1a8b76 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,40 @@ +# Build artifacts. +bin/ +out/ +*.exe +*.dll +*.so +*.dylib + +# Go test/coverage. +*.test +*.out +coverage.html +coverage.txt + +# Go workspace files. +go.work +go.work.sum + +# Editor / OS. +.vscode/ +.idea/ +*.swp +.DS_Store +Thumbs.db + +# Secrets — never commit. +.env +.env.local +*.pem +*.key + +# Generated protobuf (regenerate with `make proto`). +proto/gen/ + +# Logs. +*.log +logs/ + +# Local data. +data/ diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..9316ba7 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1.7 +# +# Multi-stage build for the x0gp api-gateway (and other Go services later). +# Target: linux/arm64 (Orange Pi 5 Pro, Raspberry Pi 5) by default. +# Override with --build-arg GOARCH=amd64 for x86 dev boxes. +# +# Stage 1: build +# Stage 2: minimal runtime on distroless base. + +ARG GO_VERSION=1.26 +ARG GOARCH=arm64 + +FROM --platform=linux/$GOARCH golang:${GO_VERSION}-alpine AS build + +WORKDIR /src + +# Cache deps first. +COPY go.mod go.sum* ./ +RUN go mod download + +# Build. +COPY . . +ARG CMD=poc-server +RUN CGO_ENABLED=0 GOOS=linux GOARCH=$GOARCH \ + go build -ldflags="-s -w" -o /out/app ./cmd/${CMD} + +# Stage 2: runtime. +FROM --platform=linux/$GOARCH gcr.io/distroless/static-debian12:nonroot AS runtime + +COPY --from=build /out/app /app + +USER nonroot:nonroot +EXPOSE 8080 +ENTRYPOINT ["/app"] diff --git a/server/Makefile b/server/Makefile new file mode 100644 index 0000000..0d281db --- /dev/null +++ b/server/Makefile @@ -0,0 +1,87 @@ +# x0gp PoC server Makefile +# +# Common targets: +# make tidy - go mod tidy +# make build - compile poc-server (linux/arm64 by default for Orange Pi 5 Pro) +# make run - build + run locally (dev mode, port 8080) +# make proto - regenerate Go from proto/ via Docker +# make test - go test ./... +# make lint - go vet + (optional) golangci-lint +# make docker - build the api-gateway image +# make compose - run dev stack (postgres + redis + prometheus + grafana + api-gateway) + +GO ?= go +APP := poc-server +BIN_DIR := bin +GOOS ?= linux +GOARCH ?= arm64 +GOARM ?= +LDFLAGS := -s -w -X main.version=$(shell git describe --tags --always --dirty 2>/dev/null || echo dev) + +# Honour explicit user overrides of GOOS/GOARCH (e.g. make build GOARCH=amd64). +ifeq ($(GOARCH),arm64) + ifeq ($(GOOS),linux) + TARGET := $(BIN_DIR)/$(APP)-linux-arm64 + endif +endif +ifeq ($(GOARCH),amd64) + ifeq ($(GOOS),linux) + TARGET := $(BIN_DIR)/$(APP)-linux-amd64 + endif +endif +ifeq ($(GOARCH),arm64) + ifeq ($(GOOS),darwin) + TARGET := $(BIN_DIR)/$(APP)-darwin-arm64 + endif +endif +ifeq ($(GOOS),windows) + TARGET := $(BIN_DIR)/$(APP)-windows-$(GOARCH).exe +endif +TARGET ?= $(BIN_DIR)/$(APP) + +.PHONY: tidy +tidy: + $(GO) mod tidy + +.PHONY: build +build: + mkdir -p $(BIN_DIR) + GOOS=$(GOOS) GOARCH=$(GOARCH) $(GO) build -ldflags "$(LDFLAGS)" -o $(TARGET) ./cmd/poc-server + @echo "built: $(TARGET)" + +.PHONY: run +run: build + X0GP_DEV_MODE=true X0GP_LOG_LEVEL=debug ./$(TARGET) + +.PHONY: proto +proto: + @echo "Regenerating protobuf Go code via buf in Docker..." + docker run --rm -v "$$PWD":/workspace -w /workspace bufbuild/buf:latest generate + @echo "Done. Generated code is in proto/gen/." + +.PHONY: test +test: + $(GO) test ./... -race -count=1 + +.PHONY: lint +lint: + $(GO) vet ./... + @command -v golangci-lint >/dev/null && golangci-lint run || echo "(golangci-lint not installed, skipping)" + +.PHONY: docker +docker: + docker build -t x0gp/api-gateway:dev -f Dockerfile . + +.PHONY: compose-up +compose-up: + docker compose up -d + +.PHONY: compose-down +compose-down: + docker compose down + +.PHONY: clean +clean: + rm -rf $(BIN_DIR) + +.DEFAULT_GOAL := build diff --git a/server/PLAN.md b/server/PLAN.md new file mode 100644 index 0000000..59b4d35 --- /dev/null +++ b/server/PLAN.md @@ -0,0 +1,502 @@ +# x0gp — Server Plan (Go) + +Документ фиксирует серверную архитектуру: транспорт, модули, протокол, +хранение, биллинг, деплой. + +Дата фиксации архитектурных решений: 2026-06-18. +Дата обновления (домашний сервер для PoC и MVP): 2026-06-18. +Дата обновления v2 (Orange Pi 5 Pro + Coolify + статический IP): 2026-06-18. + +Архитектурные выборы, на которых основан план: +1. Транспорт клиент-сервер: **WebSocket + бинарный protobuf**. +2. Управление машинками: **Wi-Fi, ESP32-S3 агент**. +3. Модель доверия: **authoritative server + client-side prediction**. +4. Позиционирование: **IR-магнитные секторы + overhead-CV (гибрид)**. + +--- + +## 1. Цели и не-цели + +### 1.1. Цели + +- Round-trip "команда на экране -> отклик машинки" в худшем случае 200 мс, + в типичном 80-120 мс. +- Поддержка одновременно 50-100 активных заездов на 1 Edge-инстанс. +- Устойчивость к обрывам связи (клиент теряет сеть на 0.5-2 с без потери + состояния). +- Anti-cheat: защита от подмены телеметрии, авто-команд, грязного руления. +- Биллинг заездов и турниров (ЮKassa для РФ, Stripe для остального). +- Горизонтальное масштабирование (stateless API, sticky-сессии на WS). + +### 1.2. Не-цели (для MVP) + +- Физика шин (скорость не считаем, считаем положение с CV). +- Запись/воспроизведение VR-реплеев. +- Полноценный matchmaking с ELO (только базовый лобби). +- Мобильное приложение (только Web, мобилка во второй очереди). + +--- + +## 2. Высокоуровневая архитектура + +``` + +----------------------+ + | React (Web client) | + | React Native (mobile)| + +----------+-----------+ + | + WebSocket (binary protobuf) + | + +----------v-----------+ + | Edge / API Gateway | + | (Go, stateless) | + +----+------+-----+----+ + | | | + +-------------+ | +-------------+ + | | | ++------v------+ +--------v-------+ +------v------+ +| Race Engine | | Realtime Hub | | REST API | +| (stateful) | | (WS fan-out) | | (chi/echo) | ++-------------+ +----------------+ +-------------+ + | | | + +---------+---------+ | + | | + +--------v--------+ | + | Redis | pub/sub, state | + +-----------------+ | + | | + +--------v--------+ | + | Postgres / TS |<-------------------+ + +-----------------+ +``` + +**Edge / API Gateway** — единая точка входа, TLS, rate-limit, auth, маршрутизация. +**Race Engine** — authoritative state гонки, tick 60 Hz. +**Realtime Hub** — WebSocket-концентратор, fan-out игрокам, сериализация. +**REST API** — управление пользователями, лобби, платежами, история. + +--- + +## 3. Транспорт + +### 3.1. WebSocket (основной) + +- Библиотека: **gorilla/websocket** или **nbio** (последняя — non-blocking, + лучше для тысяч соединений). +- Формат кадров: **бинарный protobuf** (для latency) + опционально JSON для + не-real-time сообщений (чат лобби). +- Subprotocol: `x0gp.v1`. +- Ping/pong: каждые 5 с, читаем RTT для диагностики. + +### 3.2. Альтернатива на будущее + +- WebRTC DataChannel — если потребуется суб-50 мс jitter. +- Собственный UDP+KCP — если появится конкурентная потребность. +- В плане не реализуем, но держим "port" в протоколе для миграции. + +### 3.3. REST API + +- HTTP/2, chi router. +- OpenAPI 3.1 спецификация, кодогенерация клиента. +- Авторизация: JWT (access 15 мин) + refresh token (30 дней). +- Idempotency-Key для платёжных эндпоинтов. + +--- + +## 4. Структура Go-модулей + +``` +server/ + cmd/ + api-gateway/ # REST + WS, stateless + race-engine/ # stateful, authoritative tick + agent-linker/ # запускается в помещении (на gateway-ПК) + indexer/ # пишет телеметрию в Timescale + replay-recorder/ # (опц.) пишет replay в S3 + internal/ + auth/ # JWT, сессии, refresh, MFA + billing/ # ЮKassa/Stripe, биллинг сессий + race/ # сессии, круги, DNF, рестарты, секторы + realtime/ # WS hub, fan-out, per-client очереди + transport/ # protobuf-схемы, кодеки + control/ # приём команд, rate-limit, prediction snapshots + prediction/ # client-side prediction серверные корректировки + telemetry/ # агрегация, пересылка в TS + anti_cheat/ # эвристики: sanity check, повторы, geo + tournament/ # bracket, лэддер, регистрация + storage/ + postgres/ # sqlx/pgx, миграции + redis/ # сессии, pub/sub, лобби + timescale/ # телеметрия + obs/ # логирование, метрики, трейсы + proto/ + client_server.proto # команды, телеметрия для игрока + server_agent.proto # команды и состояние для машинки + api/ + openapi.yaml + migrations/ # SQL + deploy/ + docker-compose.yml # dev + helm/ # prod + scripts/ + loadtest/ # 100 виртуальных гонщиков +``` + +--- + +## 5. Протокол (protobuf) + +### 5.1. Клиент -> Сервер + +- `ClientHello { version, token, locale }` +- `JoinRace { raceId, carClass }` +- `LeaveRace {}` +- `InputState { seq, tsMs, steering (-1..1), throttle (0..1), brake (0..1), gear (-1..0..1), buttons }` (60-120 Hz) +- `ChatMessage { text }` (rate-limited) +- `Ping { tsMs }` + +### 5.2. Сервер -> Клиент + +- `ServerHello { sessionId, raceTick, config }` +- `RaceSnapshot { tick, tsMs, cars: [{id, x, y, heading, speed, lap, sector, lastLapMs}], leaderboard }` (30-60 Hz) +- `InputAck { seq, appliedAtMs }` +- `Correction { deltaSteering, deltaThrottle, reason }` (редко, при расхождении) +- `RaceEvent { type: START|LAP|SECTOR|DNF|END, payload }` +- `TournamentUpdate { ... }` +- `Pong { tsMs }` + +### 5.3. Сервер -> Бортовой агент + +- `AgentCommand { carId, steering, throttle, brake, gear, flags, expiresAtMs }` (60-120 Hz) +- `AgentConfig { maxSteeringRate, maxThrottleRate, rampLimits }` + +### 5.4. Бортовой агент -> Сервер + +- `AgentHello { carId, fwVersion, hwId, rssi }` +- `AgentTelemetry { carId, tsMs, steeringActual, throttleActual, rpm, voltage, current, tempEsc, tempMotor, imu: {ax, ay, az, gx, gy, gz}, irSensor: {front, rear}, batteryPct }` (10-30 Hz) +- `Heartbeat { tsMs, uptimeSec, errors }` (1 Hz) +- `AgentLog { level, msg }` + +### 5.5. Gateway-агент -> Edge-сервер + +- `TrackHello { trackId, hwId, publicKey, version }` +- `CarFrame { carId, tsMs, position: {x,y,z}, heading, velocity, confidence }` (из CV, 30-60 Hz) +- `SectorEvent { carId, sector, tsMs, lap }` +- `ReplayChunk { raceId, tsMs, jpegBytes }` (по запросу) +- `LocalCommand { carId, payload }` (forwarded from server) + +--- + +## 6. Race Engine (authoritative tick) + +### 6.1. Tick + +- Фиксированный шаг: **16.67 мс (60 Hz)**. +- На каждом тике: + 1. Принять все InputState от игроков за последние N мс (батч). + 2. Применить к authoritative state машинок (с учётом ramp limits). + 3. Сформировать команды бортовым агентам. + 4. Отправить команды агенту в помещении. + 5. Через ~33 мс получить телеметрию и CV-обновления. + 6. Скорректировать state. + 7. Сформировать RaceSnapshot для клиентов. + +### 6.2. Client-side prediction + +- Клиент хранит последний применённый state и локально симулирует физику + между snapshot-ами. +- При получении Snapshot клиент сверяет, если расхождение > порога — плавная + коррекция (lerp 100-200 мс), без "телепорта". +- Сервер шлёт `Correction` только если расхождение > критического. + +### 6.3. Anti-cheat эвристики + +- Sanity check входящих команд (steering/throttle в допустимом диапазоне, + скорость изменения не выше физического предела). +- Сравнение CV-позиции с заявленной (на сервере) — если расходятся > N метров, + пометка DQF. +- Повторы replay: после заезда сверяем логгированные команды с CV-треком. +- Device fingerprint + поведенческий профиль. +- Rate limit: > 200 Hz InputState = бан. + +### 6.4. Состояния гонки + +- `LOBBY` -> `COUNTDOWN` (3..1) -> `RACING` -> `FINISHED`. +- DNF по: таймаут без input 5 с, потеря связи с агентом > 3 с, флаг + администратора. +- RESTART: только на LoBBY или между раундами. + +--- + +## 7. Realtime Hub + +- Один WS-коннект на игрока. +- Подписки: `race:{raceId}`, `tournament:{id}`, `user:{id}`. +- Fan-out: при Snapshot race-движок публикует в Redis pub/sub, Hub читает + и шлёт подписанным клиентам. +- Backpressure: если клиент не успевает читать — дропаем старые Snapshot, + шлём только последний. +- Sticky-сессии: cookie `x0gp_sticky` при балансировке на L7. + +--- + +## 8. Хранение + +### 8.1. PostgreSQL (relational) + +Таблицы (основные): +- `users` (id, email, phone, password_hash, created_at, locale) +- `sessions` (id, user_id, refresh_hash, ip, ua, expires_at) +- `wallets` (user_id, balance, currency) +- `payments` (id, user_id, provider, ext_id, amount, status, created_at) +- `races` (id, track_id, class, started_at, ended_at, winner_id, replay_url) +- `race_results` (race_id, user_id, car_id, lap_times[], best_lap, dnf_reason) +- `tournaments` (id, name, format, entry_fee, prize_pool, status) +- `tournament_entries` (tournament_id, user_id, seed, eliminated_at) +- `cars` (id, hw_id, model, fw_version, status, last_seen) +- `tracks` (id, name, layout_id, geo_city, status) + +Миграции: `golang-migrate/migrate`. + +### 8.2. Redis + +- `session:{token}` -> userId (TTL 15 мин) +- `refresh:{token}` -> userId (TTL 30 дн) +- `race:{id}:state` -> serialized race state (TTL 1 час) +- `lobby:{id}` -> set of userIds +- `ratelimit:{user}:{op}` -> token bucket +- pub/sub channel `race_events` + +### 8.3. TimescaleDB (time-series) + +- hypertable `telemetry` (ts, race_id, car_id, field, value) +- 7 дней горячих данных, потом cold в S3. +- continuous aggregates для "лучший круг на треке", "топ-скорости". + +--- + +## 9. Биллинг + +### 9.1. Провайдеры + +- **ЮKassa** — для РФ. +- **Stripe** — для остального мира (фаза 3). + +### 9.2. Сценарии + +- Пополнение кошелька (one-time payment). +- Списание за заезд (раз в N минут или за сессию). +- Входной взнос в турнир. +- Призовые начисления победителям. + +### 9.3. Идемпотентность и реконсиляция + +- Каждый платёж имеет `Idempotency-Key` на стороне клиента и `ext_id` от + провайдера. +- Webhook-эндпоинт `/webhooks/yookassa` с проверкой подписи. +- Ежедневный job сверяет балансы с провайдером. + +--- + +## 10. Авторизация + +- Email + password (argon2id) — базовый сценарий. +- OAuth Yandex / VK / Google — для удобства. +- MFA TOTP — обязательно для админов и pro-игроков. +- JWT: HS256 в PoC, RS256 в проде. +- Refresh-token rotation, device-binding. + +--- + +## 11. Anti-cheat + +Уровни: +1. **Sanity** — диапазоны, скорости, ramp limits (всегда). +2. **CV-check** — расхождение с overhead-камерой > 1 м -> пометка. +3. **Replay verify** — после заезда можно пересчитать (асинхронно). +4. **Behavioral** — паттерны (слишком "идеальные" круги = подозрительно). +5. **Manual review** — оператор смотрит replay при споре. + +--- + +## 12. Observability + +- Логи: `slog` (stdlib) в JSON, отправка в Loki. +- Метрики: `prometheus/client_golang`, экспорт `/metrics`. +- Трейсы: OpenTelemetry, OTLP в Jaeger/Tempo. +- Ключевые метрики: + - `ws_connections_total` + - `rtt_ms_p50/p95/p99` (по `(clientSendTs - serverRecvTs)`) + - `tick_lag_ms` (если тик опаздывает > 5 мс — алерт). + - `input_rate_per_client` + - `dropped_snapshots_per_client` + - `gateway_online{trackId}` (heartbeat агенты в помещении). + +--- + +## 13. Деплой и инфраструктура + +**Базовое решение: PoC и MVP крутятся на Orange Pi 5 Pro через Coolify.** +Перенос на облако/VPS — когда появится коммерческая нагрузка и SLA обязательства. + +### 13.1. Orange Pi 5 Pro (PoC и MVP) + +**Платформа: Orange Pi 5 Pro** (Rockchip RK3588S, ARM64). Уже есть в наличии. + +**Характеристики:** +- CPU: 8 cores (4× Cortex-A76 @ 2.4 GHz + 4× Cortex-A55 @ 1.8 GHz) +- RAM: 4 / 8 / 16 / 32 GB LPDDR4x (зависит от версии) +- GPU: Mali-G610 MP4 +- **NPU: 6 TOPS** (RKNN) — для CV-инференса (YOLO/ByteTrack) **на борту** +- Сеть: 2× 2.5 GbE (один WAN, один LAN с трассой — **физическая изоляция**) +- Wi-Fi 6 (резерв) +- USB 3.0 × 2, PCIe 2.1 x1, SATA +- ОС: Ubuntu (уже стоит, с **Coolify**) + +**Coolify как платформа деплоя:** +- Self-hosted PaaS, уже развёрнут. +- UI для управления сервисами, доменами, SSL, env-vars. +- Push-to-deploy из Git-репо (GitHub/Gitea). +- Сервисы = Docker Compose под капотом, но с UI и без боли. +- Встроенные: PostgreSQL, Redis, MinIO (S3-совместимое), Plausible, Uptime Kuma. +- Версионирование деплоев, rollback в 1 клик. + +**Доступ из интернета (статический IP уже есть):** +- **Основной вариант**: Caddy reverse proxy + Let's Encrypt напрямую. + Coolify умеет провизить LE-сертификаты из коробки. +- Подходит, т.к. провайдер даёт статический IP, 443/tcp открыт на роутере + через port-forward на Orange Pi. +- Cloudflare Tunnel не нужен (нет смысла добавлять лишний hop). +- WAF: Crowdsec через Coolify (опц., можно отложить). + +**Архитектура:** +``` ++---------------- ORANGE PI 5 PRO (Ubuntu + Coolify) ----------------+ +| | +| Coolify (docker compose orchestration) | +| +-- api-gateway ------------------------------------+ | +| +-- race-engine ----------------------------------+ | +| +-- postgres+timescale (через Coolify UI) | | +| +-- redis -----------------------------------+ | +| +-- caddy (LE) -----------------------------------+ | +| +-- prometheus+grafana -----------------------------------+ | +| +-- minio (S3 replays) -----------------------------------+ | +| | +| Natively (systemd) - требуют прямой доступ к железу: | +| +-- agent-linker (Wi-Fi к машинкам) | +| +-- cv-tracker (USB камеры, NPU RKNN) | +| +-- node_exporter (мониторинг) | +| | ++---------------------------------------------------------------------+ + 2.5 GbE #1 (WAN, к роутеру) 2.5 GbE #2 (LAN к трассе) + | | + vv + +--------------+ +---------------------+ + | Internet | | Switch (или точка- | + | (clients) | | точка) | + +--------------+ +----------+----------+ + | + +------v------+ + | x0gp-track | + | AP + камеры | + +-------------+ +``` + +**Изоляция сети трассы:** +- LAN-порт Orange Pi → выделенный коммутатор (или напрямую к точке доступа + x0gp-track). +- В этой сети только машинки и камеры — никаких домашних устройств. +- AP настраивается на изолированный SSID / VLAN. +- Это даёт предсказуемый latency (нет соседского трафика) и безопасность. + +**Использование NPU RK3588S:** +- RKNN Toolkit 2 для конвертации моделей (PyTorch → ONNX → RKNN). +- Поддерживаются YOLOv8/v9, ByteTrack через onnxruntime-rknn. +- Целевой инференс: 60-120 FPS на одной камере 1080p (с запасом). +- Если NPU не подходит — fallback на CPU ARM A76. +- Альтернативный inference-сервер: Ultralytics + onnxruntime-rknn. + +**ARM64-специфика:** +- Docker-образы должны быть `linux/arm64` или `multi-arch`. +- Postgres+TimescaleDB, Redis, Caddy, Prometheus, Grafana — есть для arm64. +- Go-сервисы — `GOARCH=arm64` при сборке (по умолчанию если собирать на + Orange Pi). +- CV-инференс на Python — pin версии onnxruntime-rknn под aarch64. + +**Бэкапы:** +- Ежедневный `pg_dump` (cron в Coolify или отдельный scheduled task). +- Backup в MinIO (S3-совместимое) внутри Coolify. +- Опционально off-site: rclone → Backblaze B2 / Yandex Object Storage. +- Retention: 14 дней локально, 30 дней off-site. + +**Безопасность:** +- SSH: только ключи, fail2ban. +- Coolify UI: за reverse proxy с basic auth или Cloudflare Access. +- Порты наружу: 80/tcp + 443/tcp (для Caddy/LE). +- Postgres и Redis слушают только 127.0.0.1 внутри Coolify. +- UPS для Orange Pi (microSD карты не любят внезапные отключения). + +### 13.2. Production (после MVP, когда появится коммерческая нагрузка) + +- VPS в Москве и СПб (2 региона) — Selectel / Yandex Cloud / Timeweb. +- Managed Postgres + Redis (для снятия с Orange Pi задач бэкапа и HA). +- Kubernetes (Yandex Cloud / Selectel / AWS) — HPA по метрикам + ws_connections, tick_lag. +- Redis Cluster, Postgres с read-replica. +- CDN для статики (CloudFront / Cloudflare). +- WAF (CrowdSec). +- Orange Pi остаётся как **gateway-агент + CV-инференс** в помещении с + трассой (NPU продолжаем использовать для позиционирования). +- Edge-сервер (Go) переезжает на VPS, к которому подключаются несколько + Orange Pi из разных помещений. + +--- + +## 14. Производительность и SLO + +| Метрика | Цель | +|---|---| +| API p99 latency | < 100 мс | +| WS input->ack | < 30 мс на Edge | +| Tick stability | 60 Hz +/- 2 мс | +| Один race-engine | 100 активных гонок | +| Один api-gateway | 5000 WS-клиентов | +| Доступность | 99.5% (PoC), 99.9% (prod) | + +--- + +## 15. Roadmap (server) + +| Фаза | Срок | Что делаем | +|---|---|---| +| **S0: PoC моно-сервис** | 1-2 нед | Один Go-бинарь: WS + REST + race state in-memory + локальный agent-linker. SQLite. Без биллинга. | +| **S1: MVP модульный** | 4-6 нед | Разделение на api-gateway / race-engine / agent-linker. Postgres + Redis. Auth, JWT, лобби. | +| **S2: CV + телеметрия** | 8-10 нед | Интеграция с overhead-CV. TimescaleDB. Replay recorder (S3-совместимое). | +| **S3: Биллинг** | 10-12 нед | ЮKassa, кошелёк, сессии под оплатой. Webhooks. Админка. | +| **S4: Турниры** | 14-18 нед | Bracket, лэддер, призовые. Анти-чит уровня 2-3. | +| **S5: Продакшн** | 4-6 мес | K8s, мульти-регион, SLO, on-call. Stripe для остального мира. Мобильное приложение. | + +--- + +## 16. Открытые вопросы + +- **Стек frontend**: React + Vite, какие библиотеки для WS (reconnect, + backoff)? Кандидаты: `reconnecting-websocket`, `socket.io-client` (overkill, + но быстро), свой тонкий клиент на `WebSocket` API. +- **Cloudflare Tunnel vs WireGuard vs статический IP** — что выберем для + доступа к домашнему серверу? Дефолт: Cloudflare Tunnel (проще, есть + free-tier, базовый WAF). Уточним после тестов latency. +- **Бэкапы Postgres на NAS** — есть ли дома NAS или использовать внешнее + S3-совместимое (Backblaze B2 / Yandex Object Storage) для off-site бэкапов? +- **CDN/streaming для replay** — Mux / Cloudflare Stream / своё? Out of scope + для MVP, но в плане держим "порт". +- **Интеграция со стриминговыми платформами (Twitch)** — out of scope для + MVP, но в плане держим "порт". +- **Язык CV-сервиса**: Go (gocv) или Python (быстрее итерация, ONNX-рантайм)? + Дефолт: Python на PoC для скорости итераций, Go для prod-инференса + (меньше ресурсов, проще деплой). +- **Конкретные характеристики домашнего сервера** (CPU/RAM/диск/ОС) — нужно + для оценки, сколько одновременных гонок он потянет. В PLAN.md пока заложен + разумный минимум (4 CPU / 16 GB / 500 GB SSD). +- **ЮKassa-аккаунт** — есть ли уже ИП/ООО для приёма платежей, или + регистрировать при переходе на биллинг? diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..bacf11a --- /dev/null +++ b/server/README.md @@ -0,0 +1,209 @@ +# x0gp server (PoC) + +Минимальный Go-сервер для сквозной проверки идеи x0gp: WebSocket-клиент +(браузер) отправляет команды руля/газа, сервер гоняет упрощённую +authoritative-физику на 60 Hz и шлёт снапшоты обратно. + +**Это PoC.** Без биллинга, без CV, без агента на машинке. WebSocket + +race engine + Postgres (каталог tracks / cars) + Redis pub/sub. + +--- + +## Архитектура PoC + +``` +browser -- WS(JSON) --> poc-server -- in-process --> RaceEngine (60 Hz tick) + \-> Snapshot fan-out via Hub +``` + +См. [ARCHITECTURE.md](../ARCHITECTURE.md) и [PLAN.md](./PLAN.md) для +общей картины и будущих фаз. + +--- + +## Структура + +``` +server/ + cmd/poc-server/main.go # точка входа, HTTP + WS, race engine tick + internal/ + config/ # env-конфиг + transport/ # JSON-codec (PoC), envelope, типы сообщений + control/ # authoritative race engine (tick 60 Hz, физика) + realtime/ # WebSocket hub, fan-out, backpressure + catalog/ # tracks + cars, интерфейс Store + in-memory cache + seeddefs/ # Go-определения seed-данных (общие с SQL seed) + storage/postgres/ # pgx pool, embedded миграции, PgStore + migrations/ # 001_init.sql, 002_seed.sql + scripts/genseed/ # утилита для перегенерации 002_seed.sql + proto/ # формальная спецификация (proto3) + client_server.proto + server_agent.proto + buf.yaml, buf.gen.yaml + web/ # минимальный vanilla-JS клиент + index.html, main.js, style.css + Dockerfile # multi-stage, linux/arm64 для Orange Pi 5 Pro + docker-compose.yml # dev stack: postgres+timescale, redis, prom, grafana + Makefile # build, run, proto, test, docker, compose + .gitignore + go.mod +``` + +--- + +## Быстрый старт (на Orange Pi 5 Pro) + +### 1. Подготовка + +```bash +# Установить Go 1.26+ (если ещё не стоит). +wget https://go.dev/dl/go1.26.4.linux-arm64.tar.gz +sudo tar -C /usr/local -xzf go1.26.4.linux-arm64.tar.gz +export PATH=$PATH:/usr/local/go/bin +echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc +go version # должно быть go1.26.x +``` + +### 2. Клонирование и сборка + +```bash +git clone https://github.com//x0gp.git +cd x0gp/server + +# Тянем зависимости и собираем бинарь под arm64. +make tidy +make build # -> bin/poc-server-linux-arm64 +``` + +### 3. Запуск (минимальный) + +```bash +X0GP_DEV_MODE=true ./bin/poc-server-linux-arm64 +``` + +Открыть в браузере на той же машине: `http://localhost:8080/`. +Подключиться через web-интерфейс — увидишь виртуальные машинки, которые +гоняют по сцене, реагируя на команды. + +### 4. Запуск полного dev-стека (через docker compose) + +```bash +make compose-up # поднимает postgres, redis, prometheus, grafana, api-gateway +# api-gateway будет на http://localhost:8080 +# grafana: http://localhost:3000 (admin/admin) +make compose-down # остановить +``` + +### 5. Деплой в Coolify + +(см. [../ops/HOME_SERVER.md](../ops/HOME_SERVER.md)) +- Push репо на GitHub/Gitea. +- В Coolify: New Resource → Application → подключить репо. +- Build Pack: Dockerfile. +- Port: 8080. +- Env vars: `X0GP_DEV_MODE=true` для PoC. +- Healthcheck path: `/health`. + +--- + +## Конфигурация (env vars) + +| Variable | Default | Описание | +|---|---|---| +| `X0GP_HTTP_ADDR` | `:8080` | Адрес HTTP-сервера | +| `X0GP_TICK_RATE` | `60` | Race engine tick (Гц) | +| `X0GP_SNAPSHOT_RATE` | `30` | Snapshot fan-out (Гц) | +| `X0GP_MAX_CLIENTS` | `100` | Лимит одновременных WS | +| `X0GP_LOG_LEVEL` | `info` | `debug`/`info`/`warn`/`error` | +| `X0GP_DEV_MODE` | `true` | PoC-режим: без auth, несколько клиентов | +| `DATABASE_URL` | — | **обязательно**. Строка подключения к Postgres (например `postgres://x0gp:x0gp@localhost:5432/x0gp?sslmode=disable`). Сервер не стартует без неё. | + +--- + +## WebSocket протокол + +Все кадры — JSON-объекты с полями `type`, `seq`, `ts_ms`, `payload`. + +См. [proto/client_server.proto](./proto/client_server.proto) — формальная +спецификация для будущего бинарного protobuf-формата (MVP/prod). + +### Пример: подключиться и поуправлять машинкой (из консоли) + +```bash +# Через websocat (apt install websocat). +websocat ws://localhost:8080/ws + +# Ввести: +{"type":"client_hello","payload":{"version":"0.1"}} +{"type":"join_race","payload":{"car_slot":0,"car_name":"alice"}} +{"type":"input_state","payload":{"steering":0.5,"throttle":0.8,"brake":0}} +# ... крутить рулём и газом +``` + +Сервер сразу же начнёт присылать `race_snapshot` (30 Гц) с позициями +всех машинок. + +--- + +## Метрики и логи + +- `/health` — healthcheck (JSON). +- `/stats` — текущие connection count, snapshot drops, race phase. +- `/metrics` — Prometheus metrics (будет в S1). +- Логи: JSON в stdout (slog), удобно для Loki/Promtail. + +--- + +## Хранилище (Postgres + TimescaleDB) + +Каталог (tracks / cars) хранится в Postgres. Миграции лежат в +`internal/storage/postgres/migrations/*.sql`, встроены через `//go:embed` +и применяются автоматически при старте сервера (см. +`internal/storage/postgres/postgres.go:Migrate`). Уже применённые +миграции отслеживаются в таблице `schema_migrations`. + +- `001_init.sql` — схема: `tracks`, `track_waypoints`, `track_tags`, `cars`, + плюс `CREATE EXTENSION timescaledb` (для будущего телеметрического + hypertable в S2). +- `002_seed.sql` — system-данные: 3 трассы (`oval-classic`, + `figure-eight`, `sprint-straight`) и 2 машины (`compact-1-27-touring`, + `compact-1-27-formula`). Сидовые Go-определения лежат в + `internal/catalog/seeddefs`; чтобы перегенерировать SQL, см. + `scripts/genseed`. + +Локальный стек поднимается через `make compose-up` (см. +`docker-compose.yml`). Сервер стартует **fail-fast**: если +`DATABASE_URL` не задан или БД недоступна, процесс завершается с +ошибкой и кодом 1 — без БД каталог работать не будет. + +## Команды разработки + +```bash +make tidy # go mod tidy +make build # сборка +make run # локальный запуск (dev) +make test # go test ./... +make lint # go vet + golangci-lint +make proto # регенерация proto/gen/ (через Docker buf) +make docker # собрать api-gateway image +make compose-up # поднять dev стек +make compose-down # опустить dev стек +make clean # rm -rf bin/ +``` + +--- + +## Что дальше + +См. [PLAN.md](./PLAN.md) и [../ARCHITECTURE.md](../ARCHITECTURE.md): + +- **S1 (MVP)**: добавить Postgres, Redis, JWT-auth, лобби. +- **S2**: overhead-CV на NPU Orange Pi, телеметрия в TimescaleDB. +- **S3**: биллинг через ЮKassa. +- **S4**: турниры. + +--- + +## Лицензия + +TBD (предлагается Apache 2.0). diff --git a/server/cmd/poc-server/catalog_handlers.go b/server/cmd/poc-server/catalog_handlers.go new file mode 100644 index 0000000..29cb166 --- /dev/null +++ b/server/cmd/poc-server/catalog_handlers.go @@ -0,0 +1,736 @@ +// HTTP handlers for the catalog (tracks + cars). +// +// All HTTP routes live under /api/*: +// GET /api/tracks list tracks (optional ?scope=system|public|private&tag=) +// GET /api/tracks/{id} get a single track +// POST /api/tracks create a track +// PUT /api/tracks/{id} update a track +// DELETE /api/tracks/{id} delete a track +// GET /api/cars list cars (optional ?scope=...&owner_id=...) +// GET /api/cars/{id} get a single car +// POST /api/cars create a car +// PUT /api/cars/{id} update a car +// DELETE /api/cars/{id} delete a car +// GET /api/catalog combined snapshot + summary +// +// WebSocket access is wired up directly in main.go's readPump switch. +package main + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "github.com/x0gp/server/internal/catalog" + "github.com/x0gp/server/internal/transport" +) + +// --------------------------------------------------------------------------- +// Wire <-> internal conversion +// --------------------------------------------------------------------------- + +func waypointToWire(w catalog.Waypoint) transport.TrackWaypoint { + return transport.TrackWaypoint{ + X: w.X, + Y: w.Y, + HeadingRad: w.HeadingRad, + SpeedMs: w.SpeedMs, + Curvature: w.Curvature, + } +} + +func waypointsToWire(in []catalog.Waypoint) []transport.TrackWaypoint { + if len(in) == 0 { + return nil + } + out := make([]transport.TrackWaypoint, len(in)) + for i, w := range in { + out[i] = waypointToWire(w) + } + return out +} + +func waypointsFromWire(in []transport.TrackWaypoint) []catalog.Waypoint { + if len(in) == 0 { + return nil + } + out := make([]catalog.Waypoint, len(in)) + for i, w := range in { + out[i] = catalog.Waypoint{ + X: w.X, Y: w.Y, HeadingRad: w.HeadingRad, + SpeedMs: w.SpeedMs, Curvature: w.Curvature, + } + } + return out +} + +func boundsToWire(b catalog.Bounds) transport.TrackBounds { + return transport.TrackBounds{ + MinX: b.MinX, MinY: b.MinY, MaxX: b.MaxX, MaxY: b.MaxY, + LengthM: b.LengthM, WidthM: b.WidthM, + } +} + +func trackToWire(t catalog.TrackMeta) transport.TrackWire { + return transport.TrackWire{ + ID: t.ID, + Name: t.Name, + Description: t.Description, + AuthorID: t.AuthorID, + Visibility: string(t.Visibility), + Scale: t.Scale, + LengthM: t.LengthM, + WidthM: t.WidthM, + LaneWidthM: t.LaneWidthM, + Bounds: boundsToWire(t.Bounds), + Surface: string(t.Surface), + Tags: t.Tags, + Centerline: waypointsToWire(t.Centerline), + BestLapMs: t.BestLapMs, + BestLapHolder: t.BestLapHolder, + CreatedMs: t.CreatedMs, + UpdatedMs: t.UpdatedMs, + } +} + +func tracksToWire(in []catalog.TrackMeta) []transport.TrackWire { + if len(in) == 0 { + return []transport.TrackWire{} + } + out := make([]transport.TrackWire, len(in)) + for i, t := range in { + out[i] = trackToWire(t) + } + return out +} + +func trackFromWire(w transport.TrackWire) catalog.TrackMeta { + return catalog.TrackMeta{ + ID: w.ID, + Name: w.Name, + Description: w.Description, + AuthorID: w.AuthorID, + Visibility: catalog.Visibility(w.Visibility), + Scale: w.Scale, + LengthM: w.LengthM, + WidthM: w.WidthM, + LaneWidthM: w.LaneWidthM, + Bounds: catalog.Bounds{ + MinX: w.Bounds.MinX, MinY: w.Bounds.MinY, + MaxX: w.Bounds.MaxX, MaxY: w.Bounds.MaxY, + LengthM: w.Bounds.LengthM, WidthM: w.Bounds.WidthM, + }, + Surface: catalog.Surface(w.Surface), + Tags: w.Tags, + Centerline: waypointsFromWire(w.Centerline), + BestLapMs: w.BestLapMs, + BestLapHolder: w.BestLapHolder, + CreatedMs: w.CreatedMs, + UpdatedMs: w.UpdatedMs, + } +} + +func chassisToWire(c catalog.ChassisSpec) transport.ChassisWire { + return transport.ChassisWire{ + Model: c.Model, Material: c.Material, Printed: c.Printed, + WheelbaseMm: c.WheelbaseMm, TrackMm: c.TrackMm, + } +} + +func motorToWire(m catalog.MotorSpec) transport.MotorWire { + return transport.MotorWire{ + Kind: m.Kind, Class: m.Class, KV: m.KV, PowerW: m.PowerW, + } +} + +func batteryToWire(b catalog.BatterySpec) transport.BatteryWire { + return transport.BatteryWire{ + VoltageV: b.VoltageV, CapacityMah: b.CapacityMah, + Cells: b.Cells, Chemistry: b.Chemistry, + } +} + +func carToWire(c catalog.CarMeta) transport.CarWire { + return transport.CarWire{ + ID: c.ID, + Name: c.Name, + OwnerID: c.OwnerID, + Visibility: string(c.Visibility), + Scale: c.Scale, + LengthMm: c.LengthMm, + WidthMm: c.WidthMm, + HeightMm: c.HeightMm, + WeightG: c.WeightG, + Chassis: chassisToWire(c.Chassis), + Motor: motorToWire(c.Motor), + Battery: batteryToWire(c.Battery), + Drive: string(c.Drive), + TopSpeedMs: c.TopSpeedMs, + ColorHex: c.ColorHex, + AvatarURL: c.AvatarURL, + Active: c.Active, + // Stats are read-mostly; the server fills them on read. + TotalDistanceM: c.TotalDistanceM, + TotalRaces: c.TotalRaces, + TotalLaps: c.TotalLaps, + BestLapMs: c.BestLapMs, + CreatedMs: c.CreatedMs, + UpdatedMs: c.UpdatedMs, + } +} + +func carsToWire(in []catalog.CarMeta) []transport.CarWire { + if len(in) == 0 { + return []transport.CarWire{} + } + out := make([]transport.CarWire, len(in)) + for i, c := range in { + out[i] = carToWire(c) + } + return out +} + +func carFromWire(w transport.CarWire) catalog.CarMeta { + return catalog.CarMeta{ + ID: w.ID, + Name: w.Name, + OwnerID: w.OwnerID, + Visibility: catalog.Visibility(w.Visibility), + Scale: w.Scale, + LengthMm: w.LengthMm, + WidthMm: w.WidthMm, + HeightMm: w.HeightMm, + WeightG: w.WeightG, + Chassis: catalog.ChassisSpec{ + Model: w.Chassis.Model, Material: w.Chassis.Material, + Printed: w.Chassis.Printed, WheelbaseMm: w.Chassis.WheelbaseMm, + TrackMm: w.Chassis.TrackMm, + }, + Motor: catalog.MotorSpec{ + Kind: w.Motor.Kind, Class: w.Motor.Class, + KV: w.Motor.KV, PowerW: w.Motor.PowerW, + }, + Battery: catalog.BatterySpec{ + VoltageV: w.Battery.VoltageV, CapacityMah: w.Battery.CapacityMah, + Cells: w.Battery.Cells, Chemistry: w.Battery.Chemistry, + }, + Drive: catalog.Drivetrain(w.Drive), + TopSpeedMs: w.TopSpeedMs, + ColorHex: w.ColorHex, + AvatarURL: w.AvatarURL, + Active: w.Active, + CreatedMs: w.CreatedMs, + UpdatedMs: w.UpdatedMs, + } +} + +func summaryToWire(s catalog.Stats) transport.CatalogSummary { + return transport.CatalogSummary{ + TracksTotal: s.TracksTotal, + TracksSystem: s.TracksSystem, + TracksPublic: s.TracksPublic, + TracksPrivate: s.TracksPrivate, + CarsTotal: s.CarsTotal, + CarsSystem: s.CarsSystem, + CarsPublic: s.CarsPublic, + CarsPrivate: s.CarsPrivate, + CarsActive: s.CarsActive, + TSMs: time.Now().UnixMilli(), + } +} + +// --------------------------------------------------------------------------- +// Patch helpers +// --------------------------------------------------------------------------- + +func trackPatchFromWire(p transport.TrackPatch) catalog.UpdateTrackOptions { + return catalog.UpdateTrackOptions{ + Name: p.Name, + Description: p.Description, + Visibility: visPtr(p.Visibility), + LengthM: p.LengthM, + WidthM: p.WidthM, + LaneWidthM: p.LaneWidthM, + Surface: surfacePtr(p.Surface), + Tags: p.Tags, + Centerline: waypointsFromWirePtr(p.Centerline), + } +} + +func carPatchFromWire(p transport.CarPatch) catalog.UpdateCarOptions { + return catalog.UpdateCarOptions{ + Name: p.Name, + Visibility: visPtr(p.Visibility), + LengthMm: p.LengthMm, + WidthMm: p.WidthMm, + HeightMm: p.HeightMm, + WeightG: p.WeightG, + Drive: drivePtr(p.Drive), + TopSpeedMs: p.TopSpeedMs, + ColorHex: p.ColorHex, + Active: p.Active, + Chassis: chassisFromPtr(p.Chassis), + Motor: motorFromPtr(p.Motor), + Battery: batteryFromPtr(p.Battery), + } +} + +func visPtr(p *string) *catalog.Visibility { + if p == nil { + return nil + } + v := catalog.Visibility(*p) + return &v +} + +func surfacePtr(p *string) *catalog.Surface { + if p == nil { + return nil + } + v := catalog.Surface(*p) + return &v +} + +func drivePtr(p *string) *catalog.Drivetrain { + if p == nil { + return nil + } + v := catalog.Drivetrain(*p) + return &v +} + +func waypointsFromWirePtr(p *[]transport.TrackWaypoint) *[]catalog.Waypoint { + if p == nil { + return nil + } + out := waypointsFromWire(*p) + return &out +} + +func chassisFromPtr(p *transport.ChassisWire) *catalog.ChassisSpec { + if p == nil { + return nil + } + return &catalog.ChassisSpec{ + Model: p.Model, Material: p.Material, Printed: p.Printed, + WheelbaseMm: p.WheelbaseMm, TrackMm: p.TrackMm, + } +} + +func motorFromPtr(p *transport.MotorWire) *catalog.MotorSpec { + if p == nil { + return nil + } + return &catalog.MotorSpec{ + Kind: p.Kind, Class: p.Class, KV: p.KV, PowerW: p.PowerW, + } +} + +func batteryFromPtr(p *transport.BatteryWire) *catalog.BatterySpec { + if p == nil { + return nil + } + return &catalog.BatterySpec{ + VoltageV: p.VoltageV, CapacityMah: p.CapacityMah, + Cells: p.Cells, Chemistry: p.Chemistry, + } +} + +func strFromPtr(p *string) string { + if p == nil { + return "" + } + return *p +} + +func filterTracksByTag(in []catalog.TrackMeta, tag string) []catalog.TrackMeta { + out := make([]catalog.TrackMeta, 0, len(in)) + for _, t := range in { + for _, tg := range t.Tags { + if strings.EqualFold(tg, tag) { + out = append(out, t) + break + } + } + } + return out +} + +// --------------------------------------------------------------------------- +// HTTP handlers +// --------------------------------------------------------------------------- + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeError(w http.ResponseWriter, status int, code, msg string) { + writeJSON(w, status, map[string]any{ + "ok": false, + "error": msg, + "code": code, + }) +} + +func mapCatalogError(err error) (int, string) { + switch { + case errors.Is(err, catalog.ErrNotFound): + return http.StatusNotFound, "not_found" + case errors.Is(err, catalog.ErrAlreadyExists): + return http.StatusConflict, "already_exists" + case errors.Is(err, catalog.ErrInvalidInput): + return http.StatusBadRequest, "invalid_input" + case errors.Is(err, catalog.ErrImmutableSystem): + return http.StatusForbidden, "immutable_system" + default: + return http.StatusInternalServerError, "internal" + } +} + +// catalogHandler godoc +// @Summary Combined catalog snapshot +// @Description Returns the full in-memory catalog: all tracks, all cars, and aggregate counters. This is the cheapest way for a UI to bootstrap its initial state. +// @Tags catalog +// @Produce json +// @Success 200 {object} map[string]interface{} "Snapshot of tracks, cars and summary counters" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/catalog [get] +func catalogHandler(svc *catalog.Service) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + snap := svc.Snapshot() + writeJSON(w, http.StatusOK, map[string]any{ + "tracks": tracksToWire(snap.Tracks), + "cars": carsToWire(snap.Cars), + "summary": summaryToWire(svc.Stats()), + }) + } +} + +// tracksHandler godoc +// @Summary List tracks +// @Description Returns all tracks visible to the caller. Optional query filters: `scope=system|public|private`, `tag=`. +// @Tags tracks +// @Produce json +// @Param scope query string false "Filter by visibility" Enums(system, public, private) +// @Param tag query string false "Filter by tag (case-insensitive)" +// @Success 200 {object} map[string]interface{} "List of tracks with count" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/tracks [get] +func tracksHandler(svc *catalog.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + scope := r.URL.Query().Get("scope") + tag := r.URL.Query().Get("tag") + + var out []catalog.TrackMeta + switch strings.ToLower(scope) { + case "system": + out = svc.ListTracksByVisibility(catalog.VisibilitySystem) + case "public": + out = svc.ListTracksByVisibility(catalog.VisibilityPublic) + case "private": + out = svc.ListTracksByVisibility(catalog.VisibilityPrivate) + default: + out = svc.ListTracks() + } + if tag != "" { + out = filterTracksByTag(out, tag) + } + writeJSON(w, http.StatusOK, map[string]any{ + "tracks": tracksToWire(out), + "count": len(out), + }) + } +} + +// trackByIDHandler godoc +// @Summary Get / update / delete a track by id +// @Description Dispatches on HTTP method. The id is taken from the URL path (`/api/tracks/{id}`). System tracks are immutable and cannot be updated or deleted. +// @Tags tracks +// @Produce json +// @Consume json +// @Param id path string true "Track id" +// @Param track body transport.TrackUpdateRequest false "Patch payload (only for PUT)" +// @Success 200 {object} transport.TrackWire "Track payload (GET) or update ack (PUT)" +// @Success 204 {object} transport.TrackAck "Delete ack" +// @Failure 400 {object} map[string]interface{} "Bad request / invalid input" +// @Failure 403 {object} map[string]interface{} "System track is immutable" +// @Failure 404 {object} map[string]interface{} "Track not found" +// @Failure 409 {object} map[string]interface{} "Track id already exists" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/tracks/{id} [get] +// @Router /api/tracks/{id} [put] +// @Router /api/tracks/{id} [delete] +func trackByIDHandler(svc *catalog.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/tracks/") + if id == "" || strings.Contains(id, "/") { + writeError(w, http.StatusBadRequest, "bad_request", "missing track id") + return + } + switch r.Method { + case http.MethodGet: + t, ok := svc.GetTrack(id) + if !ok { + writeError(w, http.StatusNotFound, "not_found", "track not found") + return + } + writeJSON(w, http.StatusOK, trackToWire(t)) + case http.MethodPut: + var req transport.TrackUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + if req.ID == "" { + req.ID = id + } + patch := trackPatchFromWire(req.Patch) + updated, err := svc.UpdateTrack(r.Context(), req.ID, patch) + if err != nil { + status, code := mapCatalogError(err) + writeError(w, status, code, err.Error()) + return + } + writeJSON(w, http.StatusOK, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated)}) + case http.MethodDelete: + if err := svc.DeleteTrack(r.Context(), id); err != nil { + status, code := mapCatalogError(err) + writeError(w, status, code, err.Error()) + return + } + writeJSON(w, http.StatusOK, transport.TrackAck{Ok: true, ID: id}) + default: + w.Header().Set("Allow", "GET PUT DELETE") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method) + } + } +} + +// createTrackHandler godoc +// @Summary Create a track +// @Description Creates a new track. The server fills `id` if the client sends an empty one. `author_id` should be the caller's id in production (auth is disabled in PoC). +// @Tags tracks +// @Accept json +// @Produce json +// @Param track body transport.TrackCreateRequest true "Track to create" +// @Success 201 {object} transport.TrackAck "Created" +// @Failure 400 {object} map[string]interface{} "Invalid input" +// @Failure 409 {object} map[string]interface{} "Track id already exists" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/tracks [post] +func createTrackHandler(svc *catalog.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req transport.TrackCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + in := trackFromWire(req.Track) + created, err := svc.CreateTrack(r.Context(), catalog.CreateTrackOptions{ + ID: in.ID, + Name: in.Name, + Description: in.Description, + AuthorID: in.AuthorID, + Visibility: in.Visibility, + Scale: in.Scale, + LengthM: in.LengthM, + WidthM: in.WidthM, + LaneWidthM: in.LaneWidthM, + Surface: in.Surface, + Tags: in.Tags, + Centerline: in.Centerline, + }) + if err != nil { + status, code := mapCatalogError(err) + writeError(w, status, code, err.Error()) + return + } + writeJSON(w, http.StatusCreated, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created)}) + } +} + +// carsHandler godoc +// @Summary List cars +// @Description Returns all cars visible to the caller. Optional query filters: `scope=system|public|private`, `owner_id=`. +// @Tags cars +// @Produce json +// @Param scope query string false "Filter by visibility" Enums(system, public, private) +// @Param owner_id query string false "Filter by owner id" +// @Success 200 {object} map[string]interface{} "List of cars with count" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/cars [get] +func carsHandler(svc *catalog.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + scope := r.URL.Query().Get("scope") + owner := r.URL.Query().Get("owner_id") + + var out []catalog.CarMeta + if owner != "" { + out = svc.ListCarsByOwner(owner) + } else { + switch strings.ToLower(scope) { + case "system": + out = svc.ListCarsByVisibility(catalog.VisibilitySystem) + case "public": + out = svc.ListCarsByVisibility(catalog.VisibilityPublic) + case "private": + out = svc.ListCarsByVisibility(catalog.VisibilityPrivate) + default: + out = svc.ListCars() + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "cars": carsToWire(out), + "count": len(out), + }) + } +} + +// carByIDHandler godoc +// @Summary Get / update / delete a car by id +// @Description Dispatches on HTTP method. The id is taken from the URL path (`/api/cars/{id}`). System cars are immutable and cannot be updated or deleted. +// @Tags cars +// @Produce json +// @Consume json +// @Param id path string true "Car id" +// @Param car body transport.CarUpdateRequest false "Patch payload (only for PUT)" +// @Success 200 {object} transport.CarWire "Car payload (GET) or update ack (PUT)" +// @Success 204 {object} transport.CarAck "Delete ack" +// @Failure 400 {object} map[string]interface{} "Bad request / invalid input" +// @Failure 403 {object} map[string]interface{} "System car is immutable" +// @Failure 404 {object} map[string]interface{} "Car not found" +// @Failure 409 {object} map[string]interface{} "Car id already exists" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/cars/{id} [get] +// @Router /api/cars/{id} [put] +// @Router /api/cars/{id} [delete] +func carByIDHandler(svc *catalog.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/cars/") + if id == "" || strings.Contains(id, "/") { + writeError(w, http.StatusBadRequest, "bad_request", "missing car id") + return + } + switch r.Method { + case http.MethodGet: + c, ok := svc.GetCar(id) + if !ok { + writeError(w, http.StatusNotFound, "not_found", "car not found") + return + } + writeJSON(w, http.StatusOK, carToWire(c)) + case http.MethodPut: + var req transport.CarUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + if req.ID == "" { + req.ID = id + } + patch := carPatchFromWire(req.Patch) + updated, err := svc.UpdateCar(r.Context(), req.ID, patch) + if err != nil { + status, code := mapCatalogError(err) + writeError(w, status, code, err.Error()) + return + } + writeJSON(w, http.StatusOK, transport.CarAck{Ok: true, ID: updated.ID, Car: carToWire(updated)}) + case http.MethodDelete: + if err := svc.DeleteCar(r.Context(), id); err != nil { + status, code := mapCatalogError(err) + writeError(w, status, code, err.Error()) + return + } + writeJSON(w, http.StatusOK, transport.CarAck{Ok: true, ID: id}) + default: + w.Header().Set("Allow", "GET PUT DELETE") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method) + } + } +} + +// createCarHandler godoc +// @Summary Create a car +// @Description Creates a new car. The server fills `id` if the client sends an empty one. `owner_id` should be the caller's id in production (auth is disabled in PoC). +// @Tags cars +// @Accept json +// @Produce json +// @Param car body transport.CarCreateRequest true "Car to create" +// @Success 201 {object} transport.CarAck "Created" +// @Failure 400 {object} map[string]interface{} "Invalid input" +// @Failure 409 {object} map[string]interface{} "Car id already exists" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/cars [post] +func createCarHandler(svc *catalog.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req transport.CarCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + in := carFromWire(req.Car) + created, err := svc.CreateCar(r.Context(), catalog.CreateCarOptions{ + ID: in.ID, + Name: in.Name, + OwnerID: in.OwnerID, + Visibility: in.Visibility, + Scale: in.Scale, + LengthMm: in.LengthMm, + WidthMm: in.WidthMm, + HeightMm: in.HeightMm, + WeightG: in.WeightG, + Chassis: in.Chassis, + Motor: in.Motor, + Battery: in.Battery, + Drive: in.Drive, + TopSpeedMs: in.TopSpeedMs, + ColorHex: in.ColorHex, + AvatarURL: in.AvatarURL, + }) + if err != nil { + status, code := mapCatalogError(err) + writeError(w, status, code, err.Error()) + return + } + writeJSON(w, http.StatusCreated, transport.CarAck{Ok: true, ID: created.ID, Car: carToWire(created)}) + } +} + +// --------------------------------------------------------------------------- +// Method routers +// --------------------------------------------------------------------------- + +// tracksRouter routes /api/tracks to either list (GET) or create (POST). +func tracksRouter(svc *catalog.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + tracksHandler(svc)(w, r) + case http.MethodPost: + createTrackHandler(svc)(w, r) + default: + w.Header().Set("Allow", "GET POST") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method) + } + } +} + +// carsRouter routes /api/cars to either list (GET) or create (POST). +func carsRouter(svc *catalog.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + carsHandler(svc)(w, r) + case http.MethodPost: + createCarHandler(svc)(w, r) + default: + w.Header().Set("Allow", "GET POST") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method) + } + } +} diff --git a/server/cmd/poc-server/catalog_ws.go b/server/cmd/poc-server/catalog_ws.go new file mode 100644 index 0000000..989ba04 --- /dev/null +++ b/server/cmd/poc-server/catalog_ws.go @@ -0,0 +1,299 @@ +// WebSocket catalog handlers + event broadcaster. +// +// The HTTP side lives in catalog_handlers.go. This file owns the WS side: +// - the catalogBroadcaster subscribes to catalog events and pushes +// track_snapshot / car_snapshot / catalog_summary frames to all +// connected clients via the realtime hub. +// - handleTrackWSMessage / handleCarWSMessage dispatch individual +// track_* / car_* requests from a single client. +package main + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "time" + + "github.com/x0gp/server/internal/catalog" + "github.com/x0gp/server/internal/realtime" + "github.com/x0gp/server/internal/transport" +) + +// wsCtx returns a context for catalog mutations triggered from a +// WebSocket message. We deliberately use Background() because the +// per-message pgxpool query is already bounded by pgx's own +// statement_timeout / connection settings. +func wsCtx() context.Context { + return context.Background() +} + +// --------------------------------------------------------------------------- +// Broadcaster +// --------------------------------------------------------------------------- + +type catalogBroadcaster struct { + svc *catalog.Service + hub *Hub + log *slog.Logger + stop chan struct{} + done chan struct{} +} + +func newCatalogBroadcaster(svc *catalog.Service, hub *Hub, log *slog.Logger) *catalogBroadcaster { + return &catalogBroadcaster{ + svc: svc, + hub: hub, + log: log, + stop: make(chan struct{}), + done: make(chan struct{}), + } +} + +func (cb *catalogBroadcaster) Run() { + defer close(cb.done) + events, cancel := cb.svc.Subscribe() + defer cancel() + for { + select { + case <-cb.stop: + return + case ev, ok := <-events: + if !ok { + return + } + cb.broadcast(ev) + } + } +} + +func (cb *catalogBroadcaster) Stop() { + select { + case <-cb.stop: + return + default: + close(cb.stop) + } + <-cb.done +} + +func (cb *catalogBroadcaster) broadcast(ev catalog.Event) { + switch ev.Kind { + case catalog.EventSnapshot: + // Initial subscription push; clients request via track_list/car_list. + return + case catalog.EventTrackUpsert, catalog.EventTrackDelete: + snap := cb.svc.Snapshot() + cb.hub.Publish(&transport.Envelope{ + Type: transport.TypeTrackSnapshot, + Payload: transport.TrackSnapshot{ + GeneratedMs: snap.GeneratedMs, + Version: snap.Version, + Tracks: tracksToWire(snap.Tracks), + }, + }) + cb.hub.Publish(&transport.Envelope{ + Type: transport.TypeCatalogSummary, + Payload: summaryToWire(cb.svc.Stats()), + }) + case catalog.EventCarUpsert, catalog.EventCarDelete: + snap := cb.svc.Snapshot() + cb.hub.Publish(&transport.Envelope{ + Type: transport.TypeCarSnapshot, + Payload: transport.CarSnapshot{ + GeneratedMs: snap.GeneratedMs, + Version: snap.Version, + Cars: carsToWire(snap.Cars), + }, + }) + cb.hub.Publish(&transport.Envelope{ + Type: transport.TypeCatalogSummary, + Payload: summaryToWire(cb.svc.Stats()), + }) + } +} + +// --------------------------------------------------------------------------- +// Per-message WS handlers +// --------------------------------------------------------------------------- + +// handleTrackWSMessage is called from the readPump dispatch on track_* messages. +func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transport.Envelope) { + payloadBytes, _ := json.Marshal(env.Payload) + switch env.Type { + case transport.TypeTrackList: + var req transport.TrackListRequest + _ = json.Unmarshal(payloadBytes, &req) + var out []catalog.TrackMeta + switch strings.ToLower(req.Scope) { + case "system": + out = svc.ListTracksByVisibility(catalog.VisibilitySystem) + case "public": + out = svc.ListTracksByVisibility(catalog.VisibilityPublic) + case "private": + out = svc.ListTracksByVisibility(catalog.VisibilityPrivate) + default: + out = svc.ListTracks() + } + if req.Tag != "" { + out = filterTracksByTag(out, req.Tag) + } + snap := svc.Snapshot() + sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{ + GeneratedMs: snap.GeneratedMs, + Version: snap.Version, + Tracks: tracksToWire(out), + }) + case transport.TypeTrackGet: + var req transport.TrackGetRequest + _ = json.Unmarshal(payloadBytes, &req) + if req.ID == "" { + sendErrorWS(c, "track_get", "missing id") + return + } + t, ok := svc.GetTrack(req.ID) + if !ok { + sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: "not_found"}) + return + } + sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{ + Tracks: []transport.TrackWire{trackToWire(t)}, + }) + case transport.TypeTrackCreate: + var req transport.TrackCreateRequest + _ = json.Unmarshal(payloadBytes, &req) + in := trackFromWire(req.Track) + created, err := svc.CreateTrack(wsCtx(), catalog.CreateTrackOptions{ + ID: in.ID, Name: in.Name, Description: in.Description, + AuthorID: in.AuthorID, Visibility: in.Visibility, Scale: in.Scale, + LengthM: in.LengthM, WidthM: in.WidthM, LaneWidthM: in.LaneWidthM, + Surface: in.Surface, Tags: in.Tags, Centerline: in.Centerline, + }) + if err != nil { + sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, Error: err.Error()}) + return + } + sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created)}) + case transport.TypeTrackUpdate: + var req transport.TrackUpdateRequest + _ = json.Unmarshal(payloadBytes, &req) + patch := trackPatchFromWire(req.Patch) + updated, err := svc.UpdateTrack(wsCtx(), req.ID, patch) + if err != nil { + sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: err.Error()}) + return + } + sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated)}) + case transport.TypeTrackDelete: + var req transport.TrackDeleteRequest + _ = json.Unmarshal(payloadBytes, &req) + if err := svc.DeleteTrack(wsCtx(), req.ID); err != nil { + sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: err.Error()}) + return + } + sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: req.ID}) + } +} + +func handleCarWSMessage(c *realtime.Client, svc *catalog.Service, env *transport.Envelope) { + payloadBytes, _ := json.Marshal(env.Payload) + switch env.Type { + case transport.TypeCarList: + var req transport.CarListRequest + _ = json.Unmarshal(payloadBytes, &req) + var out []catalog.CarMeta + if req.OwnerID != "" { + out = svc.ListCarsByOwner(req.OwnerID) + } else { + switch strings.ToLower(req.Scope) { + case "system": + out = svc.ListCarsByVisibility(catalog.VisibilitySystem) + case "public": + out = svc.ListCarsByVisibility(catalog.VisibilityPublic) + case "private": + out = svc.ListCarsByVisibility(catalog.VisibilityPrivate) + default: + out = svc.ListCars() + } + } + snap := svc.Snapshot() + sendWS(c, transport.TypeCarSnapshot, transport.CarSnapshot{ + GeneratedMs: snap.GeneratedMs, + Version: snap.Version, + Cars: carsToWire(out), + }) + case transport.TypeCarGet: + var req transport.CarGetRequest + _ = json.Unmarshal(payloadBytes, &req) + if req.ID == "" { + sendErrorWS(c, "car_get", "missing id") + return + } + car, ok := svc.GetCar(req.ID) + if !ok { + sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: false, ID: req.ID, Error: "not_found"}) + return + } + sendWS(c, transport.TypeCarSnapshot, transport.CarSnapshot{ + Cars: []transport.CarWire{carToWire(car)}, + }) + case transport.TypeCarCreate: + var req transport.CarCreateRequest + _ = json.Unmarshal(payloadBytes, &req) + in := carFromWire(req.Car) + created, err := svc.CreateCar(wsCtx(), catalog.CreateCarOptions{ + ID: in.ID, Name: in.Name, OwnerID: in.OwnerID, Visibility: in.Visibility, + Scale: in.Scale, LengthMm: in.LengthMm, WidthMm: in.WidthMm, + HeightMm: in.HeightMm, WeightG: in.WeightG, Chassis: in.Chassis, + Motor: in.Motor, Battery: in.Battery, Drive: in.Drive, + TopSpeedMs: in.TopSpeedMs, ColorHex: in.ColorHex, AvatarURL: in.AvatarURL, + }) + if err != nil { + sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: false, Error: err.Error()}) + return + } + sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: true, ID: created.ID, Car: carToWire(created)}) + case transport.TypeCarUpdate: + var req transport.CarUpdateRequest + _ = json.Unmarshal(payloadBytes, &req) + patch := carPatchFromWire(req.Patch) + updated, err := svc.UpdateCar(wsCtx(), req.ID, patch) + if err != nil { + sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: false, ID: req.ID, Error: err.Error()}) + return + } + sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: true, ID: updated.ID, Car: carToWire(updated)}) + case transport.TypeCarDelete: + var req transport.CarDeleteRequest + _ = json.Unmarshal(payloadBytes, &req) + if err := svc.DeleteCar(wsCtx(), req.ID); err != nil { + sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: false, ID: req.ID, Error: err.Error()}) + return + } + sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: true, ID: req.ID}) + } +} + +// --------------------------------------------------------------------------- +// Send helpers (non-blocking on full send buffer) +// --------------------------------------------------------------------------- + +func sendWS(c *realtime.Client, t transport.MessageType, payload any) { + data, err := transport.Encode(&transport.Envelope{ + Type: t, + TSMs: time.Now().UnixMilli(), + Payload: payload, + }) + if err != nil { + return + } + select { + case c.Send <- data: + default: + // Drop if the client is too slow. + } +} + +func sendErrorWS(c *realtime.Client, code, msg string) { + sendWS(c, transport.TypeError, transport.ErrorMsg{Code: code, Message: msg}) +} diff --git a/server/cmd/poc-server/drivers_clans_handlers.go b/server/cmd/poc-server/drivers_clans_handlers.go new file mode 100644 index 0000000..d338cff --- /dev/null +++ b/server/cmd/poc-server/drivers_clans_handlers.go @@ -0,0 +1,418 @@ +// HTTP handlers for /api/drivers and /api/clans. +// +// Routes: +// +// POST /api/clans create clan +// GET /api/clans list clans (limit, offset) +// GET /api/clans/{id} get clan by id +// PUT /api/clans/{id} update clan +// DELETE /api/clans/{id} delete clan +// GET /api/clans/by-tag/{tag} get clan by 3-letter tag +// +// POST /api/drivers create driver +// GET /api/drivers list drivers (limit, offset, clan_id) +// GET /api/drivers/{id} get driver by id +// PUT /api/drivers/{id} update driver +// DELETE /api/drivers/{id} delete driver +// GET /api/drivers/by-nick/{nick} get driver by 3-letter nickname +package main + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/x0gp/server/internal/clans" + "github.com/x0gp/server/internal/drivers" + "github.com/x0gp/server/internal/transport" +) + +// --------------------------------------------------------------------------- +// /api/clans +// --------------------------------------------------------------------------- + +// clansCreateHandler godoc +// @Summary Create a clan +// @Description Creates a new clan. `tag` must be exactly 3 uppercase ASCII letters (e.g. "ACE") and unique. +// @Tags clans +// @Accept json +// @Produce json +// @Param clan body transport.ClanCreateRequest true "Clan to create" +// @Success 201 {object} transport.Clan "Created clan" +// @Failure 400 {object} map[string]interface{} "Invalid input (tag must be 3 uppercase letters)" +// @Failure 409 {object} map[string]interface{} "Clan with that tag already exists" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/clans [post] +func clansCreateHandler(svc *clans.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req transport.ClanCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + c, err := svc.Create(r.Context(), clans.CreateInput{ + ID: req.ID, + Tag: req.Tag, + Name: req.Name, + AvatarURL: req.AvatarURL, + }) + if err != nil { + if errors.Is(err, clans.ErrInvalidInput) { + writeError(w, http.StatusBadRequest, "invalid_input", err.Error()) + return + } + if errors.Is(err, clans.ErrAlreadyExists) { + writeError(w, http.StatusConflict, "already_exists", err.Error()) + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusCreated, clanToWire(c)) + } +} + +// clansListHandler godoc +// @Summary List clans +// @Description Returns clans ordered by tag. Limit 1..200, default 50. +// @Tags clans +// @Produce json +// @Param limit query int false "Page size" +// @Param offset query int false "Page offset" +// @Success 200 {object} transport.ClanListResponse "Page of clans" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/clans [get] +func clansListHandler(svc *clans.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + rows, err := svc.List(r.Context(), limit, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + out := make([]transport.Clan, 0, len(rows)) + for _, c := range rows { + out = append(out, clanToWire(c)) + } + writeJSON(w, http.StatusOK, transport.ClanListResponse{Items: out, Count: len(out)}) + } +} + +// clansByIDHandler godoc +// @Summary Get / update / delete a clan by id +// @Description Dispatches on HTTP method. +// @Tags clans +// @Produce json +// @Consume json +// @Param id path string true "Clan id" +// @Param clan body transport.ClanUpdateRequest false "Patch payload (only for PUT)" +// @Success 200 {object} transport.Clan "Clan payload or update result" +// @Success 204 {object} transport.Clan "Delete ack" +// @Failure 400 {object} map[string]interface{} "Bad request" +// @Failure 404 {object} map[string]interface{} "Clan not found" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/clans/{id} [get] +// @Router /api/clans/{id} [put] +// @Router /api/clans/{id} [delete] +func clansByIDHandler(svc *clans.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/clans/") + if id == "" { + writeError(w, http.StatusBadRequest, "bad_request", "missing clan id") + return + } + // Special-case: /api/clans/by-tag/{tag} + if strings.HasPrefix(id, "by-tag/") { + tag := strings.TrimPrefix(id, "by-tag/") + if tag == "" { + writeError(w, http.StatusBadRequest, "bad_request", "missing clan tag") + return + } + c, err := svc.GetByTag(r.Context(), tag) + if err != nil { + if errors.Is(err, clans.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "clan not found") + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, clanToWire(c)) + return + } + switch r.Method { + case http.MethodGet: + c, err := svc.Get(r.Context(), id) + if err != nil { + if errors.Is(err, clans.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "clan not found") + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, clanToWire(c)) + case http.MethodPut: + var req transport.ClanUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + c, err := svc.Update(r.Context(), id, clans.UpdateInput{ + Name: req.Name, + AvatarURL: req.AvatarURL, + }) + if err != nil { + if errors.Is(err, clans.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "clan not found") + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, clanToWire(c)) + case http.MethodDelete: + if err := svc.Delete(r.Context(), id); err != nil { + if errors.Is(err, clans.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "clan not found") + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id}) + default: + w.Header().Set("Allow", "GET PUT DELETE") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method) + } + } +} + +// --------------------------------------------------------------------------- +// /api/drivers +// --------------------------------------------------------------------------- + +// driversCreateHandler godoc +// @Summary Create a driver +// @Description Creates a new driver. `nickname` must be exactly 3 uppercase ASCII letters (e.g. "ACE") and unique. `clan_id` is optional and must reference an existing clan. +// @Tags drivers +// @Accept json +// @Produce json +// @Param driver body transport.DriverCreateRequest true "Driver to create" +// @Success 201 {object} transport.Driver "Created driver" +// @Failure 400 {object} map[string]interface{} "Invalid input (nickname must be 3 uppercase letters)" +// @Failure 409 {object} map[string]interface{} "Driver with that nickname already exists" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/drivers [post] +func driversCreateHandler(svc *drivers.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req transport.DriverCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + d, err := svc.Create(r.Context(), drivers.CreateInput{ + ID: req.ID, + Nickname: req.Nickname, + Name: req.Name, + AvatarURL: req.AvatarURL, + ClanID: req.ClanID, + }) + if err != nil { + if errors.Is(err, drivers.ErrInvalidInput) { + writeError(w, http.StatusBadRequest, "invalid_input", err.Error()) + return + } + if errors.Is(err, drivers.ErrAlreadyExists) { + writeError(w, http.StatusConflict, "already_exists", err.Error()) + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusCreated, driverToWire(d)) + } +} + +// driversListHandler godoc +// @Summary List drivers +// @Description Returns drivers ordered by nickname. Optional `clan_id` filter. +// @Tags drivers +// @Produce json +// @Param limit query int false "Page size" +// @Param offset query int false "Page offset" +// @Param clan_id query string false "Filter by clan id" +// @Success 200 {object} transport.DriverListResponse "Page of drivers" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/drivers [get] +func driversListHandler(svc *drivers.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + clanID := r.URL.Query().Get("clan_id") + rows, err := svc.List(r.Context(), limit, offset, clanID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + out := make([]transport.Driver, 0, len(rows)) + for _, d := range rows { + out = append(out, driverToWire(d)) + } + writeJSON(w, http.StatusOK, transport.DriverListResponse{Items: out, Count: len(out)}) + } +} + +// driversByIDHandler godoc +// @Summary Get / update / delete a driver by id +// @Description Dispatches on HTTP method. Path `/api/drivers/by-nick/{nick}` is a convenience that returns the driver by 3-letter nickname. +// @Tags drivers +// @Produce json +// @Consume json +// @Param id path string true "Driver id" +// @Param driver body transport.DriverUpdateRequest false "Patch payload (only for PUT)" +// @Success 200 {object} transport.Driver "Driver payload or update result" +// @Success 204 {object} transport.Driver "Delete ack" +// @Failure 400 {object} map[string]interface{} "Bad request" +// @Failure 404 {object} map[string]interface{} "Driver not found" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/drivers/{id} [get] +// @Router /api/drivers/{id} [put] +// @Router /api/drivers/{id} [delete] +func driversByIDHandler(svc *drivers.Service) 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.HasPrefix(id, "by-nick/") { + nick := strings.TrimPrefix(id, "by-nick/") + if nick == "" { + writeError(w, http.StatusBadRequest, "bad_request", "missing driver nickname") + return + } + d, err := svc.GetByNickname(r.Context(), nick) + 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 + } + writeJSON(w, http.StatusOK, driverToWire(d)) + return + } + switch r.Method { + case http.MethodGet: + d, err := svc.Get(r.Context(), id) + 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 + } + writeJSON(w, http.StatusOK, driverToWire(d)) + case http.MethodPut: + 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{ + Name: req.Name, + AvatarURL: req.AvatarURL, + ClanID: req.ClanID, + }) + 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 + } + writeJSON(w, http.StatusOK, driverToWire(d)) + case http.MethodDelete: + if err := svc.Delete(r.Context(), id); 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 + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id}) + default: + w.Header().Set("Allow", "GET PUT DELETE") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method) + } + } +} + +// --------------------------------------------------------------------------- +// Conversions +// --------------------------------------------------------------------------- + +func clanToWire(c clans.Clan) transport.Clan { + return transport.Clan{ + ID: c.ID, + Tag: c.Tag, + Name: c.Name, + AvatarURL: c.AvatarURL, + CreatedMs: c.CreatedMs, + UpdatedMs: c.UpdatedMs, + } +} + +func driverToWire(d drivers.Driver) transport.Driver { + return transport.Driver{ + ID: d.ID, + Nickname: d.Nickname, + Name: d.Name, + AvatarURL: d.AvatarURL, + ClanID: d.ClanID, + CreatedMs: d.CreatedMs, + UpdatedMs: d.UpdatedMs, + } +} + +// --------------------------------------------------------------------------- +// Method routers +// --------------------------------------------------------------------------- + +// clansRouter routes /api/clans to GET (list) or POST (create). +func clansRouter(svc *clans.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + clansListHandler(svc)(w, r) + case http.MethodPost: + clansCreateHandler(svc)(w, r) + default: + w.Header().Set("Allow", "GET POST") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method) + } + } +} + +// driversRouter routes /api/drivers to GET (list) or POST (create). +func driversRouter(svc *drivers.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + driversListHandler(svc)(w, r) + case http.MethodPost: + driversCreateHandler(svc)(w, r) + default: + w.Header().Set("Allow", "GET POST") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method) + } + } +} diff --git a/server/cmd/poc-server/main.go b/server/cmd/poc-server/main.go new file mode 100644 index 0000000..b39b820 --- /dev/null +++ b/server/cmd/poc-server/main.go @@ -0,0 +1,709 @@ +// Command poc-server is the minimal x0gp server for end-to-end PoC. +// +// Topology (PoC): +// +// browser -- WS --> poc-server -- in-process --> RaceEngine (60 Hz tick) +// \-> Snapshot fan-out via Hub +// +// What's NOT here (intentionally, for PoC simplicity): +// - auth / JWT (DevMode = true) +// - Postgres / Redis (in-memory state) +// - agent-linker (no real cars yet) +// - overhead-CV +// - billing +// - rate limiting beyond per-send-buffer drop +// +// @title x0gp PoC server API +// @version 0.1.0-poc +// @description Minimal HTTP+WebSocket server for the x0gp RC racing PoC. REST endpoints under /api/* manage the track/car catalog; /health and /stats expose server liveness and realtime metrics; /ws carries the realtime race protocol. +// @description Auth is intentionally disabled in PoC mode (X0GP_DEV_MODE=true). All catalog mutations are allowed without a JWT. +// @BasePath / +// @schemes http ws +// @host localhost:8080 +// @contact.name x0gp +// @license.name MIT +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" + httpSwagger "github.com/swaggo/http-swagger" + + // Side-effect import: pulls in the generated OpenAPI spec at init(). + // `swag init` (see Makefile / docs/) regenerates this file from + // the // @... annotations on the handlers. + _ "github.com/x0gp/server/docs" + + "github.com/x0gp/server/internal/catalog" + "github.com/x0gp/server/internal/clans" + "github.com/x0gp/server/internal/config" + "github.com/x0gp/server/internal/control" + "github.com/x0gp/server/internal/drivers" + "github.com/x0gp/server/internal/lobby" + "github.com/x0gp/server/internal/races" + "github.com/x0gp/server/internal/races/seed" + "github.com/x0gp/server/internal/realtime" + "github.com/x0gp/server/internal/transport" + "github.com/x0gp/server/internal/storage/postgres" +) + +const serverVersion = "0.1.0-poc" + +var ( + upgrader = websocket.Upgrader{ + ReadBufferSize: 4096, + WriteBufferSize: 4096, + CheckOrigin: func(r *http.Request) bool { return true }, // PoC: dev only + } +) + +func main() { + var ( + addr = flag.String("addr", "", "override HTTP listen address") + devMode = flag.Bool("dev", true, "enable development mode") + seedRaces = flag.Bool("seed-races", false, "seed mock race data (finished/live/plans) on startup") + seedReset = flag.Bool("reset", false, "with --seed-races, wipe seed-managed data before inserting") + ) + flag.Parse() + + if *addr != "" { + _ = os.Setenv("X0GP_HTTP_ADDR", *addr) + } + if !*devMode { + _ = os.Setenv("X0GP_DEV_MODE", "false") + } + if *seedRaces { + _ = os.Setenv("X0GP_SEED_RACES", "1") + } + if *seedReset { + _ = os.Setenv("X0GP_SEED_RESET", "1") + } + + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "config error: %v\n", err) + os.Exit(1) + } + + logger := newLogger(cfg.LogLevel) + logger.Info("starting x0gp poc-server", + "version", serverVersion, + "addr", cfg.HTTPAddr, + "tick_hz", cfg.TickRate, + "snapshot_hz", cfg.SnapshotRate, + "dev_mode", cfg.DevMode, + ) + + ctx, cancel := signal.NotifyContext(context.Background(), + syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + // --- Database: fail-fast. --- + if cfg.DatabaseURL == "" { + logger.Error("DATABASE_URL is required") + os.Exit(1) + } + bootCtx, bootCancel := context.WithTimeout(ctx, 15*time.Second) + pool, err := postgres.Open(bootCtx, postgres.Config{URL: cfg.DatabaseURL}) + bootCancel() + if err != nil { + logger.Error("postgres connect failed", "err", err) + os.Exit(1) + } + defer pool.Close() + + migCtx, migCancel := context.WithTimeout(ctx, 30*time.Second) + if err := postgres.Migrate(migCtx, pool); err != nil { + migCancel() + logger.Error("postgres migrate failed", "err", err) + os.Exit(1) + } + migCancel() + logger.Info("postgres ready") + + engine := control.NewEngine(cfg.TickRate) + hub := NewHub(logger) + pgStore := postgres.NewPgStore(pool) + cat, err := catalog.NewService(ctx, pgStore) + if err != nil { + logger.Error("catalog load failed", "err", err) + os.Exit(1) + } + defer cat.Stop() + cb := newCatalogBroadcaster(cat, hub, logger) + + // Lobby + races: live races live in lobby.Service, finished races get + // snapshotted into Postgres via the lifecycle hook. + lobbySvc := lobby.NewService() + racesPgStore := races.NewPgStore(pool) + racesSvc := races.NewService(racesPgStore, lobbySvc) + liveStore := races.NewLiveStore(racesPgStore) + lobbySvc.SetPersistence(liveStore) + racesSvc.SetLiveStore(liveStore) + + // Restore the in-memory lobby from Postgres so active races and + // driver presence survive a restart. + restoreCtx, restoreCancel := context.WithTimeout(ctx, 5*time.Second) + if racesRestored, driversRestored, err := racesSvc.RestoreFromDB(restoreCtx); err != nil { + logger.Warn("restore from db failed", "err", err) + } else { + logger.Info("restored lobby from db", "races", racesRestored, "drivers", driversRestored) + } + restoreCancel() + + // Drivers and clans. + clansSvc := clans.NewService(clans.NewPgStore(pool)) + driversSvc := drivers.NewService(drivers.NewPgStore(pool)) + + // Persist finished races so the /api/races list and keyset pagination + // can serve historical data across restarts. The snapshot is best- + // effort: errors are logged but never block the lobby. + lobbySvc.SetRaceLifecycleHook( + func(meta lobby.RaceMeta) { + logger.Debug("race created", "race_id", meta.ID) + }, + func(raceID string) { + meta, err := lobbySvc.GetRace(raceID) + if err != nil { + return + } + if meta.Status != lobby.RaceStatusFinished { + return + } + hookCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := racesSvc.PersistFinished(hookCtx, races.SnapshotFinished{ + Meta: meta, + FinishedMs: time.Now().UnixMilli(), + }); err != nil { + logger.Warn("persist finished race failed", "race_id", raceID, "err", err) + } + }, + ) + + // Scheduler: ticks every 5s, materialises due race_plans into the lobby + // and auto-attaches queued drivers. + scheduler := races.NewScheduler(racesPgStore, lobbySvc, 5*time.Second) + + // Optional: seed mock race data on startup. + if os.Getenv("X0GP_SEED_RACES") == "1" { + seedCtx, seedCancel := context.WithTimeout(ctx, 30*time.Second) + runner := seed.NewRunner(racesPgStore, lobbySvc, liveStore, clans.NewPgStore(pool), drivers.NewPgStore(pool)) + reset := os.Getenv("X0GP_SEED_RESET") == "1" + summary, err := runner.Run(seedCtx, seed.Options{ + Reset: reset, + Now: time.Now(), + }) + seedCancel() + if err != nil { + logger.Error("seed races failed", "err", err) + os.Exit(1) + } + logger.Info("seed races done", + "finished", summary.FinishedInserted, + "live", summary.LiveCreated, + "plans", summary.PlansInserted, + "queue", summary.QueueInserted, + "reset", reset, + "dur_ms", summary.Duration.Milliseconds(), + ) + } + + var wg sync.WaitGroup + wg.Add(4) + + go func() { + defer wg.Done() + hub.Run(ctx) + }() + go func() { + defer wg.Done() + engine.Run(ctx) + }() + go func() { + defer wg.Done() + cb.Run() + }() + go func() { + defer wg.Done() + scheduler.Run(ctx) + }() + + // Snapshot publisher: engine -> hub. + wg.Add(1) + go func() { + defer wg.Done() + snapshotLoop(ctx, engine, hub, logger) + }() + + mux := http.NewServeMux() + mux.HandleFunc("/health", healthHandler(hub, engine)) + 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/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/clans", clansRouter(clansSvc)) + mux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc)) + mux.HandleFunc("/api/drivers", driversRouter(driversSvc)) + mux.HandleFunc("/api/drivers/", driversByIDHandler(driversSvc)) + mux.HandleFunc("/ws", wsHandler(cfg, engine, hub, cat, logger)) + + // Swagger UI + OpenAPI spec. + // UI: GET /swagger/index.html + // Spec: GET /swagger/doc.json + mux.Handle("/swagger/", httpSwagger.WrapHandler) + + srv := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: withLogging(mux, logger), + ReadHeaderTimeout: 5 * time.Second, + } + + // Start HTTP server. + go func() { + logger.Info("http listening", "addr", cfg.HTTPAddr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("http server failed", "err", err) + cancel() + } + }() + + <-ctx.Done() + logger.Info("shutdown signal received") + + shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutCancel() + _ = srv.Shutdown(shutCtx) + + engine.Stop() + hub.Stop() + cb.Stop() + wg.Wait() + logger.Info("shutdown complete") +} + +// snapshotLoop forwards RaceEngine snapshots to all connected clients. +func snapshotLoop(ctx context.Context, e *control.Engine, h *Hub, logger *slog.Logger) { + for { + select { + case <-ctx.Done(): + return + case snap, ok := <-e.Snapshots(): + if !ok { + return + } + h.BroadcastSnapshot(snap) + } + } +} + +// HubWrapper extends realtime.Hub with helpers tailored to this binary. +// Using a thin wrapper keeps realtime package free of transport-specific deps. +type Hub struct { + *realtime.Hub +} + +func NewHub(logger *slog.Logger) *Hub { + return &Hub{Hub: realtime.NewHub()} +} + +// BroadcastSnapshot wraps a RaceSnapshot in an envelope and broadcasts. +func (h *Hub) BroadcastSnapshot(snap transport.RaceSnapshot) { + h.Publish(&transport.Envelope{ + Type: transport.TypeRaceSnapshot, + TSMs: time.Now().UnixMilli(), + Payload: snap, + }) +} + +// HTTP handlers ------------------------------------------------------------ + +// healthHandler godoc +// @Summary Liveness probe +// @Description Returns 200 OK with current engine phase, WS connection count and total snapshot drops. Intended for load-balancer health checks. +// @Tags system +// @Produce json +// @Success 200 {object} map[string]interface{} "Server is up" +// @Router /health [get] +func healthHandler(h *Hub, e *control.Engine) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + stats := h.Stats() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "version": serverVersion, + "phase": e.Phase().String(), + "tick": 0, // Engine has no public getter; would add later + "connections": stats.Connections, + "snapshot_drops": stats.DropsTotal, + "time": time.Now().UTC().Format(time.RFC3339Nano), + }) + } +} + +// statsHandler godoc +// @Summary Realtime runtime stats +// @Description Returns the current race phase plus realtime counters: active WS connections and total snapshots dropped due to slow consumers. +// @Tags system +// @Produce json +// @Success 200 {object} map[string]interface{} "Runtime counters" +// @Router /stats [get] +func statsHandler(h *Hub, e *control.Engine) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + stats := h.Stats() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "connections": stats.Connections, + "snapshot_drops": stats.DropsTotal, + "race_phase": e.Phase().String(), + }) + } +} + +// wsHandler godoc +// @Summary WebSocket endpoint (binary JSON envelope protocol) +// @Description Opens a bidirectional WebSocket for the realtime race protocol. The server replies with `server_hello`, then broadcasts `race_snapshot` frames at `X0GP_SNAPSHOT_HZ`. +// +// @Description ## Frame format +// @Description Every frame is a JSON envelope of the form: +// @Description ```json +// @Description { "type": "", "seq": 42, "ts_ms": 1700000000000, "payload": { ... } } +// @Description ``` +// @Description `type` discriminates the payload; `payload` is documented per message type in the schemas listed in `components/schemas` (see `Envelope`, `ClientHello`, `ServerHello`, `InputState`, `JoinRace`, `LeaveRace`, `RaceSnapshot`, `InputAck`, `RaceEvent`, `Ping`, `Pong`, `ErrorMsg`, plus the catalog/lobby/stats envelopes). +// @Description ## Client -> Server +// @Description - `client_hello` — first frame after connect. Sent once. +// @Description - `join_race` / `leave_race` — join or leave the race session. +// @Description - `input_state` — high-rate control (steering/throttle/brake/gear). Send at 60-120 Hz. +// @Description - `ping` — round-trip time probe. +// @Description - `track_*` / `car_*` — catalog CRUD (mirror of the REST /api/* endpoints). +// @Description - `lobby_*` — lobby management. +// @Description - `stats_request` — ask for a stats snapshot. +// @Description ## Server -> Client +// @Description - `server_hello` — handshake reply; includes session id and engine config. +// @Description - `race_snapshot` — periodic authoritative state broadcast. +// @Description - `input_ack` — per-input acknowledgement (optional; drop-tolerant). +// @Description - `race_event` — start/lap/sector/dnf/finish. +// @Description - `track_snapshot` / `car_snapshot` / `catalog_summary` — catalog feeds on change. +// @Description - `lobby_snapshot` — full lobby state on change. +// @Description - `stats_snapshot` / `race_standings` / `lap_recorded` / `race_result` — stats feeds. +// @Description - `error` — protocol or validation error. +// @Description ## Notes +// @Description - Origin checks are disabled in PoC mode (`CheckOrigin: always-allow`). +// @Description - Ping interval: 20 s. Pong wait: 60 s. Max frame size: 8 KiB. +// @Description - Input acks are best-effort and silently dropped under load. +// @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, logger *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + logger.Warn("ws upgrade failed", "err", err) + return + } + client := &realtime.Client{ + ID: uuid.NewString(), + Send: make(chan []byte, 64), + Done: make(chan struct{}), + } + h.Register(client) + logger.Info("client connected", + "client_id", client.ID, + "remote", r.RemoteAddr, + ) + go writePump(client, conn, logger) + go readPump(cfg, client, conn, e, h, cat, logger) + } +} + +// Per-connection goroutines ---------------------------------------------- + +const ( + pongWait = 60 * time.Second + pingPeriod = 20 * time.Second + writeTimeout = 5 * time.Second +) + +func writePump(c *realtime.Client, conn *websocket.Conn, logger *slog.Logger) { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + _ = conn.Close() + c.Close() + }() + for { + select { + case <-c.Done: + return + case msg, ok := <-c.Send: + if !ok { + _ = conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + _ = conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { + logger.Debug("ws write failed", "client_id", c.ID, "err", err) + return + } + case <-ticker.C: + _ = conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *control.Engine, h *Hub, cat *catalog.Service, logger *slog.Logger) { + defer func() { + h.Unregister(c) + logger.Info("client disconnected", "client_id", c.ID) + _ = conn.Close() + }() + + conn.SetReadLimit(8192) + _ = conn.SetReadDeadline(time.Now().Add(pongWait)) + conn.SetPongHandler(func(string) error { + _ = conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + var seq uint64 + + for { + _, raw, err := conn.ReadMessage() + if err != nil { + return + } + + env, err := transport.Decode(raw) + if err != nil { + sendError(c, "decode_error", err.Error()) + continue + } + seq++ + env.Seq = seq + + switch env.Type { + case transport.TypeClientHello: + handleClientHello(c, e, cfg, env) + case transport.TypeJoinRace: + handleJoinRace(c, e, env) + case transport.TypeLeaveRace: + handleLeaveRace(c, e, env) + case transport.TypeInputState: + handleInput(c, e, env) + case transport.TypePing: + handlePing(c, env) + case transport.TypeChatMessage: + // Not implemented in PoC; acknowledge silently. + case transport.TypeTrackList, + transport.TypeTrackGet, + transport.TypeTrackCreate, + transport.TypeTrackUpdate, + transport.TypeTrackDelete: + handleTrackWSMessage(c, cat, env) + case transport.TypeCarList, + transport.TypeCarGet, + transport.TypeCarCreate, + transport.TypeCarUpdate, + transport.TypeCarDelete: + handleCarWSMessage(c, cat, env) + default: + sendError(c, "unknown_type", string(env.Type)) + } + } +} + +// Message handlers -------------------------------------------------------- + +func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config, env *transport.Envelope) { + hello := transport.ServerHello{ + SessionID: c.ID, + RaceTick: 0, + } + hello.ServerVersion = serverVersion + hello.Config.TickRate = cfg.TickRate + hello.Config.SnapshotRate = cfg.SnapshotRate + hello.Config.MaxCars = 4 + c.SessionID = c.ID + + c.Send <- mustEncode(transport.TypeServerHello, hello) +} + +func handleJoinRace(c *realtime.Client, e *control.Engine, env *transport.Envelope) { + payload, _ := env.Payload.(map[string]any) + slot := 0 + name := "anon" + if payload != nil { + if v, ok := payload["car_slot"].(float64); ok { + slot = int(v) + } + if v, ok := payload["car_name"].(string); ok && v != "" { + name = v + } + } + car, err := e.AddCar(c.ID, name, slot) + if err != nil { + sendError(c, "join_failed", err.Error()) + return + } + c.Send <- mustEncode(transport.TypeRaceEvent, transport.RaceEvent{ + Event: "joined", + CarID: car.ID, + TSMs: time.Now().UnixMilli(), + }) +} + +func handleLeaveRace(c *realtime.Client, e *control.Engine, _ *transport.Envelope) { + e.RemoveCar(c.ID) +} + +func handleInput(c *realtime.Client, e *control.Engine, env *transport.Envelope) { + payload, ok := env.Payload.(map[string]any) + if !ok { + return + } + in := transport.InputState{ + Steering: getFloat(payload, "steering"), + Throttle: getFloat(payload, "throttle"), + Brake: getFloat(payload, "brake"), + } + if v, ok := payload["gear"].(float64); ok { + in.Gear = int(v) + } + car := e.GetCarByDriver(c.ID) + if car == nil { + return + } + car.ApplyInput(in, 1.0/60.0) + + // Optional lightweight ack (clients can disable for max throughput). + ack := transport.InputAck{ + Seq: env.Seq, + AppliedAtMs: time.Now().UnixMilli(), + ServerTickMs: time.Now().UnixMilli(), + } + select { + case c.Send <- mustEncode(transport.TypeInputAck, ack): + default: + // Drop ack silently. + } +} + +func handlePing(c *realtime.Client, env *transport.Envelope) { + payload, _ := env.Payload.(map[string]any) + clientTS := int64(0) + if payload != nil { + if v, ok := payload["client_ts_ms"].(float64); ok { + clientTS = int64(v) + } + } + c.Send <- mustEncode(transport.TypePong, transport.Pong{ + ClientTSMs: clientTS, + ServerTSMs: time.Now().UnixMilli(), + }) +} + +// Helpers ------------------------------------------------------------------ + +func sendError(c *realtime.Client, code, msg string) { + select { + case c.Send <- mustEncode(transport.TypeError, transport.ErrorMsg{ + Code: code, Message: msg, + }): + default: + } +} + +func mustEncode(t transport.MessageType, payload any) []byte { + data, err := transport.Encode(&transport.Envelope{ + Type: t, + TSMs: time.Now().UnixMilli(), + Payload: payload, + }) + if err != nil { + return nil + } + return data +} + +func getFloat(m map[string]any, key string) float64 { + if v, ok := m[key].(float64); ok { + return v + } + return 0 +} + +func newLogger(level string) *slog.Logger { + var lvl slog.Level + switch level { + case "debug": + lvl = slog.LevelDebug + case "warn": + lvl = slog.LevelWarn + case "error": + lvl = slog.LevelError + default: + lvl = slog.LevelInfo + } + h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}) + return slog.New(h) +} + +func withLogging(next http.Handler, logger *slog.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rw := &statusRecorder{ResponseWriter: w, status: 200} + next.ServeHTTP(rw, r) + logger.Debug("http", + "method", r.Method, + "path", r.URL.Path, + "status", rw.status, + "dur_ms", time.Since(start).Milliseconds(), + ) + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} + +// Hijack passes through to the underlying ResponseWriter so that the +// gorilla/websocket upgrader can take over the connection. +func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if h, ok := r.ResponseWriter.(http.Hijacker); ok { + return h.Hijack() + } + return nil, nil, http.ErrNotSupported +} diff --git a/server/cmd/poc-server/races_handlers.go b/server/cmd/poc-server/races_handlers.go new file mode 100644 index 0000000..911010f --- /dev/null +++ b/server/cmd/poc-server/races_handlers.go @@ -0,0 +1,506 @@ +// HTTP handlers for the races surface (list / upcoming / queue / plans). +// +// Routes: +// +// GET /api/races list races (keyset paginated) +// GET /api/races/upcoming next N lobby|countdown races +// POST /api/races/queue/join enqueue driver on the next N upcoming +// GET /api/races/queue list driver's queue entries +// DELETE /api/races/queue remove a single (driver, race) entry +// POST /api/races/plans create a race plan +// GET /api/races/plans list race plans (keyset paginated) +// DELETE /api/races/plans/{id} delete a plan +// +// Query parameters: +// +// GET /api/races: +// status comma-separated list, e.g. "racing,finished" +// track_id filter by physical track id (no custom lobbies yet) +// cursor opaque keyset cursor (returned in next_cursor) +// limit max items per page (default 50, max 200) +// +// GET /api/races/upcoming: +// limit number of races to return (default 3, max 16) +// +// GET /api/races/queue: +// driver_id (or X-Driver-Id header) +// +// GET /api/races/plans: +// cursor, limit +package main + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/x0gp/server/internal/lobby" + "github.com/x0gp/server/internal/races" + "github.com/x0gp/server/internal/transport" +) + +// --------------------------------------------------------------------------- +// /api/races +// --------------------------------------------------------------------------- + +// racesListHandler godoc +// @Summary List races (keyset paginated) +// @Description Returns one keyset page of races. Live races (status lobby|racing) come from in-memory lobby.Service; finished races come from Postgres. The two sources are merged and ordered by (started_ms DESC, id ASC). The cursor is opaque: pass back the next_cursor verbatim from a previous page. +// @Description ## Filters +// @Description - `status` (comma-separated, e.g. "racing,finished"). Recognised values: all, lobby, racing, finished. +// @Description - `track_id` (exact match against a physical track id; custom lobbies are not supported in this build). +// @Description - `limit` (1..200, default 50) +// @Tags races +// @Produce json +// @Param status query string false "Comma-separated status filter" Enums(all, lobby, racing, finished) +// @Param track_id query string false "Filter by physical track id" +// @Param cursor query string false "Opaque keyset cursor (next_cursor from previous page)" +// @Param limit query int false "Page size (1..200, default 50)" +// @Success 200 {object} transport.RaceListResponse "Page of races" +// @Failure 400 {object} map[string]interface{} "Bad request / invalid cursor / invalid status" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/races [get] +func racesListHandler(svc *races.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + filter, err := parseRaceListFilter(r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + res, err := svc.ListRaces(r.Context(), filter) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + items := make([]transport.LobbyRace, 0, len(res.Items)) + for _, it := range res.Items { + if it.Source == "finished" && len(it.Podium) > 0 { + items = append(items, lobbyToWireFinished(it.Meta, it.Podium)) + } else { + items = append(items, lobbyToWire(it.Meta)) + } + } + writeJSON(w, http.StatusOK, transport.RaceListResponse{ + Items: items, + NextCursor: res.NextCursor, + Count: len(items), + }) + } +} + +func parseRaceListFilter(r *http.Request) (races.ListFilter, error) { + q := r.URL.Query() + cur, err := races.DecodeCursor(q.Get("cursor")) + if err != nil { + return races.ListFilter{}, err + } + statuses, err := races.ParseStatusFilter(q.Get("status")) + if err != nil { + return races.ListFilter{}, err + } + limit, _ := strconv.Atoi(q.Get("limit")) + return races.ListFilter{ + Statuses: statuses, + TrackID: q.Get("track_id"), + Cursor: cur, + Limit: limit, + }, nil +} + +// --------------------------------------------------------------------------- +// /api/races/upcoming +// --------------------------------------------------------------------------- + +// racesUpcomingHandler godoc +// @Summary Next N upcoming races +// @Description Returns up to `limit` races currently in status lobby|countdown, ordered soonest first. Useful for the "join queue" UI and dashboard. +// @Tags races +// @Produce json +// @Param limit query int false "Number of races to return (1..16, default 3)" +// @Success 200 {object} transport.RaceUpcomingResponse "Upcoming races" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/races/upcoming [get] +func racesUpcomingHandler(svc *races.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { + limit = 3 + } + items, err := svc.Upcoming(r.Context(), limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + out := make([]transport.RaceUpcomingItem, 0, len(items)) + for _, it := range items { + out = append(out, transport.RaceUpcomingItem{ + Meta: lobbyToWire(it.Meta), + QueueLen: it.QueueLen, + PlanID: it.PlanID, + StartAtMs: it.StartAtMs, + }) + } + writeJSON(w, http.StatusOK, transport.RaceUpcomingResponse{Items: out, Count: len(out)}) + } +} + +// --------------------------------------------------------------------------- +// /api/races/queue +// --------------------------------------------------------------------------- + +// racesQueueJoinHandler godoc +// @Summary Enqueue driver on next N upcoming races +// @Description Puts the driver in the queue for each of the next N (default 3) lobby|countdown races, soonest first. If a race has a free slot, the driver is also attached to it (best effort). Idempotent per (driver, race). +// @Tags races +// @Accept json +// @Produce json +// @Param X-Driver-Id header string false "Driver id (alternative to body)" +// @Param body body transport.RaceQueueJoinRequest false "Driver id and optional limit" +// @Success 200 {object} transport.RaceQueueJoinResponse "Queue entries created" +// @Failure 400 {object} map[string]interface{} "driver_id required" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/races/queue/join [post] +func racesQueueJoinHandler(svc *races.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req transport.RaceQueueJoinRequest + if r.ContentLength > 0 { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + } + driverID := req.DriverID + if driverID == "" { + driverID = r.Header.Get("X-Driver-Id") + } + if driverID == "" { + driverID = r.URL.Query().Get("driver_id") + } + if driverID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "driver_id required") + return + } + limit := req.Limit + if limit <= 0 { + limit = 3 + } + res, err := svc.JoinUpcoming(r.Context(), driverID, limit) + if err != nil { + if errors.Is(err, races.ErrInvalidInput) { + writeError(w, http.StatusBadRequest, "invalid_input", err.Error()) + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + joined := make([]transport.RaceQueueEntry, 0, len(res.Joined)) + for _, q := range res.Joined { + joined = append(joined, transport.RaceQueueEntry{ + DriverID: q.DriverID, + RaceID: q.RaceID, + PlanID: q.PlanID, + EnqueuedMs: q.EnqueuedMs, + }) + } + writeJSON(w, http.StatusOK, transport.RaceQueueJoinResponse{ + Joined: joined, + Skipped: res.Skipped, + }) + } +} + +// racesQueueListHandler godoc +// @Summary List driver's queue entries +// @Description Returns the queue entries for a driver, oldest first. +// @Tags races +// @Produce json +// @Param X-Driver-Id header string false "Driver id" +// @Param driver_id query string false "Driver id (alternative to header)" +// @Success 200 {object} transport.RaceQueueListResponse "Queue entries" +// @Failure 400 {object} map[string]interface{} "driver_id required" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/races/queue [get] +func racesQueueListHandler(svc *races.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + driverID := r.URL.Query().Get("driver_id") + if driverID == "" { + driverID = r.Header.Get("X-Driver-Id") + } + if driverID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "driver_id required") + return + } + entries, err := svc.ListQueue(r.Context(), driverID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + out := make([]transport.RaceQueueEntry, 0, len(entries)) + for _, q := range entries { + out = append(out, transport.RaceQueueEntry{ + DriverID: q.DriverID, + RaceID: q.RaceID, + PlanID: q.PlanID, + EnqueuedMs: q.EnqueuedMs, + }) + } + writeJSON(w, http.StatusOK, transport.RaceQueueListResponse{Items: out, Count: len(out)}) + } +} + +// racesQueueDeleteHandler godoc +// @Summary Leave a queue entry +// @Description Removes a single (driver, race) entry from the queue. Does not detach the driver from the race if already joined. +// @Tags races +// @Produce json +// @Param X-Driver-Id header string false "Driver id" +// @Param driver_id query string false "Driver id" +// @Param race_id query string true "Race id" +// @Success 200 {object} map[string]interface{} "ok" +// @Failure 400 {object} map[string]interface{} "driver_id and race_id required" +// @Failure 404 {object} map[string]interface{} "queue entry not found" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/races/queue [delete] +func racesQueueDeleteHandler(svc *races.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + driverID := r.URL.Query().Get("driver_id") + raceID := r.URL.Query().Get("race_id") + if driverID == "" { + driverID = r.Header.Get("X-Driver-Id") + } + if driverID == "" || raceID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "driver_id and race_id required") + return + } + if err := svc.LeaveQueue(r.Context(), driverID, raceID); err != nil { + if errors.Is(err, races.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "queue entry not found") + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + } +} + +// --------------------------------------------------------------------------- +// /api/races/plans +// --------------------------------------------------------------------------- + +// racePlansCreateHandler godoc +// @Summary Create a race plan +// @Description Creates a recurring or one-off scheduled race. The scheduler materialises a lobby race when `next_fire_ms` <= now. If `interval_s` > 0 the plan repeats; if `count` > 0 the plan disables itself after that many fires. +// @Tags plans +// @Accept json +// @Produce json +// @Param plan body transport.RacePlanCreateRequest true "Plan to create" +// @Success 201 {object} transport.RacePlan "Created plan" +// @Failure 400 {object} map[string]interface{} "Invalid input" +// @Failure 409 {object} map[string]interface{} "Plan id already exists" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/races/plans [post] +func racePlansCreateHandler(svc *races.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req transport.RacePlanCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + p, err := svc.CreatePlan(r.Context(), races.CreatePlanInput{ + ID: req.ID, + Name: req.Name, + TrackID: req.TrackID, + MaxCars: req.MaxCars, + Laps: req.Laps, + TimeLimitS: req.TimeLimitS, + StartAtMs: req.StartAtMs, + IntervalS: req.IntervalS, + Count: req.Count, + Enabled: enabled, + }) + if err != nil { + if errors.Is(err, races.ErrInvalidInput) { + writeError(w, http.StatusBadRequest, "invalid_input", err.Error()) + return + } + if errors.Is(err, races.ErrAlreadyExists) { + writeError(w, http.StatusConflict, "already_exists", err.Error()) + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusCreated, racePlanToWire(p)) + } +} + +// racePlansListHandler godoc +// @Summary List race plans (keyset paginated) +// @Description Returns race plans in ascending start_at order. Pass back the `next_cursor` to get the next page. +// @Tags plans +// @Produce json +// @Param cursor query string false "Opaque keyset cursor" +// @Param limit query int false "Page size (default 50)" +// @Success 200 {object} transport.RacePlanListResponse "Page of plans" +// @Failure 400 {object} map[string]interface{} "Invalid cursor" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/races/plans [get] +func racePlansListHandler(svc *races.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cur, err := races.DecodeCursor(r.URL.Query().Get("cursor")) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { + limit = 50 + } + plans, err := svc.ListPlans(r.Context(), cur, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + hasMore := false + if len(plans) > limit { + hasMore = true + plans = plans[:limit] + } + out := make([]transport.RacePlan, 0, len(plans)) + for _, p := range plans { + out = append(out, racePlanToWire(p)) + } + nextCur := "" + if hasMore && len(out) > 0 { + last := out[len(out)-1] + nextCur = races.Cursor{Ms: last.StartAtMs, ID: last.ID}.Encode() + } + writeJSON(w, http.StatusOK, transport.RacePlanListResponse{ + Items: out, + NextCursor: nextCur, + Count: len(out), + }) + } +} + +// racePlansDeleteHandler godoc +// @Summary Delete a race plan +// @Description Removes a plan by id. Materialised races already in the lobby are not affected. +// @Tags plans +// @Produce json +// @Param id path string true "Plan id" +// @Success 200 {object} map[string]interface{} "ok" +// @Failure 404 {object} map[string]interface{} "Plan not found" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/races/plans/{id} [delete] +func racePlansDeleteHandler(svc *races.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/races/plans/") + if id == "" || strings.Contains(id, "/") { + writeError(w, http.StatusBadRequest, "bad_request", "missing plan id") + return + } + if err := svc.DeletePlan(r.Context(), id); err != nil { + if errors.Is(err, races.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "plan not found") + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id}) + } +} + +// --------------------------------------------------------------------------- +// Method routers +// --------------------------------------------------------------------------- + +// racesQueueRouter routes /api/races/queue to GET (list) or DELETE (leave). +func racesQueueRouter(svc *races.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + racesQueueListHandler(svc)(w, r) + case http.MethodDelete: + racesQueueDeleteHandler(svc)(w, r) + default: + w.Header().Set("Allow", "GET DELETE") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method) + } + } +} + +// racePlansRouter routes /api/races/plans to GET (list) or POST (create). +func racePlansRouter(svc *races.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + racePlansListHandler(svc)(w, r) + case http.MethodPost: + racePlansCreateHandler(svc)(w, r) + default: + w.Header().Set("Allow", "GET POST") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method) + } + } +} + +func lobbyToWire(m lobby.RaceMeta) transport.LobbyRace { + return transport.LobbyRace{ + ID: m.ID, + Name: m.Name, + TrackID: m.TrackID, + MaxCars: m.MaxCars, + Laps: m.Laps, + TimeLimitS: m.TimeLimitS, + DriverIDs: append([]string(nil), m.DriverIDs...), + Status: string(m.Status), + CreatedMs: m.CreatedMs, + StartedMs: m.StartedMs, + } +} + +// lobbyToWireFinished builds the wire form for a finished race and +// attaches the top-3 podium. +func lobbyToWireFinished(m lobby.RaceMeta, podium []races.PodiumEntry) transport.LobbyRace { + w := lobbyToWire(m) + if len(podium) > 0 { + w.Podium = make([]transport.RacePodiumEntry, 0, len(podium)) + for _, p := range podium { + w.Podium = append(w.Podium, transport.RacePodiumEntry{ + Position: p.Position, + DriverID: p.DriverID, + Name: p.Name, + TotalTimeMs: p.TotalTimeMs, + }) + } + } + return w +} + +func racePlanToWire(p races.RacePlan) transport.RacePlan { + return transport.RacePlan{ + ID: p.ID, + Name: p.Name, + TrackID: p.TrackID, + MaxCars: p.MaxCars, + Laps: p.Laps, + TimeLimitS: p.TimeLimitS, + StartAtMs: p.StartAtMs, + IntervalS: p.IntervalS, + Count: p.Count, + Enabled: p.Enabled, + CreatedMs: p.CreatedMs, + UpdatedMs: p.UpdatedMs, + NextFireMs: p.NextFireMs, + FiresDone: p.FiresDone, + } +} diff --git a/server/deploy/prometheus.yml b/server/deploy/prometheus.yml new file mode 100644 index 0000000..04b9443 --- /dev/null +++ b/server/deploy/prometheus.yml @@ -0,0 +1,16 @@ +# Prometheus configuration for the x0gp dev stack. +# Run via `make compose-up`; Prometheus is exposed on http://localhost:9090. + +global: + scrape_interval: 5s + evaluation_interval: 10s + +scrape_configs: + - job_name: x0gp-api-gateway + static_configs: + - targets: ["api-gateway:8080"] + metrics_path: /metrics + + - job_name: prometheus + static_configs: + - targets: ["localhost:9090"] diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..08b05d6 --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,90 @@ +name: x0gp-dev + +# Dev stack for the PoC. Mirrors what Coolify will provision on the Orange +# Pi 5 Pro. Services run on a private network; only the api-gateway is +# published to localhost. +# +# Usage: +# make compose-up +# make compose-down +# +# Notes: +# - All images use linux/arm64 by default (Orange Pi 5 Pro). Override with +# --platform=linux/amd64 if you're on x86. +# - Persistent volumes: postgres-data, redis-data, prom-data, grafana-data. +# - On a fresh install, run `make proto` first to generate proto/gen/. + +services: + postgres: + image: timescale/timescaledb:latest-pg16 + restart: unless-stopped + platform: linux/arm64 + environment: + POSTGRES_USER: x0gp + POSTGRES_PASSWORD: x0gp + POSTGRES_DB: x0gp + volumes: + - postgres-data:/var/lib/postgresql/data + ports: + - "127.0.0.1:5434:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U x0gp"] + interval: 5s + timeout: 3s + retries: 10 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redis-data:/data + ports: + - "127.0.0.1:6379:6379" + + api-gateway: + build: + context: . + dockerfile: Dockerfile + args: + GOARCH: arm64 + CMD: poc-server + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + environment: + X0GP_HTTP_ADDR: ":8080" + X0GP_TICK_RATE: "60" + X0GP_SNAPSHOT_RATE: "30" + X0GP_LOG_LEVEL: "info" + X0GP_DEV_MODE: "true" + DATABASE_URL: "postgres://x0gp:x0gp@postgres:5432/x0gp?sslmode=disable" + REDIS_URL: "redis://redis:6379" + ports: + - "127.0.0.1:8080:8080" + + prometheus: + image: prom/prometheus:latest + restart: unless-stopped + volumes: + - ./deploy/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prom-data:/prometheus + ports: + - "127.0.0.1:9090:9090" + + grafana: + image: grafana/grafana:latest + restart: unless-stopped + volumes: + - grafana-data:/var/lib/grafana + ports: + - "127.0.0.1:3000:3000" + +volumes: + postgres-data: + redis-data: + prom-data: + grafana-data: diff --git a/server/docs/docs.go b/server/docs/docs.go new file mode 100644 index 0000000..6b86914 --- /dev/null +++ b/server/docs/docs.go @@ -0,0 +1,2734 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": { + "name": "x0gp" + }, + "license": { + "name": "MIT" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/cars": { + "get": { + "description": "Returns all cars visible to the caller. Optional query filters: ` + "`" + `scope=system|public|private` + "`" + `, ` + "`" + `owner_id=\u003cstring\u003e` + "`" + `.", + "produces": [ + "application/json" + ], + "tags": [ + "cars" + ], + "summary": "List cars", + "parameters": [ + { + "enum": [ + "system", + "public", + "private" + ], + "type": "string", + "description": "Filter by visibility", + "name": "scope", + "in": "query" + }, + { + "type": "string", + "description": "Filter by owner id", + "name": "owner_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of cars with count", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "Creates a new car. The server fills ` + "`" + `id` + "`" + ` if the client sends an empty one. ` + "`" + `owner_id` + "`" + ` should be the caller's id in production (auth is disabled in PoC).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cars" + ], + "summary": "Create a car", + "parameters": [ + { + "description": "Car to create", + "name": "car", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/transport.CarCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/transport.CarAck" + } + }, + "400": { + "description": "Invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Car id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/cars/{id}": { + "get": { + "description": "Dispatches on HTTP method. The id is taken from the URL path (` + "`" + `/api/cars/{id}` + "`" + `). System cars are immutable and cannot be updated or deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "cars" + ], + "summary": "Get / update / delete a car by id", + "parameters": [ + { + "type": "string", + "description": "Car id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "car", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.CarUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Car payload (GET) or update ack (PUT)", + "schema": { + "$ref": "#/definitions/transport.CarWire" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.CarAck" + } + }, + "400": { + "description": "Bad request / invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "403": { + "description": "System car is immutable", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Car not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Car id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "description": "Dispatches on HTTP method. The id is taken from the URL path (` + "`" + `/api/cars/{id}` + "`" + `). System cars are immutable and cannot be updated or deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "cars" + ], + "summary": "Get / update / delete a car by id", + "parameters": [ + { + "type": "string", + "description": "Car id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "car", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.CarUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Car payload (GET) or update ack (PUT)", + "schema": { + "$ref": "#/definitions/transport.CarWire" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.CarAck" + } + }, + "400": { + "description": "Bad request / invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "403": { + "description": "System car is immutable", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Car not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Car id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "Dispatches on HTTP method. The id is taken from the URL path (` + "`" + `/api/cars/{id}` + "`" + `). System cars are immutable and cannot be updated or deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "cars" + ], + "summary": "Get / update / delete a car by id", + "parameters": [ + { + "type": "string", + "description": "Car id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "car", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.CarUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Car payload (GET) or update ack (PUT)", + "schema": { + "$ref": "#/definitions/transport.CarWire" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.CarAck" + } + }, + "400": { + "description": "Bad request / invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "403": { + "description": "System car is immutable", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Car not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Car id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/catalog": { + "get": { + "description": "Returns the full in-memory catalog: all tracks, all cars, and aggregate counters. This is the cheapest way for a UI to bootstrap its initial state.", + "produces": [ + "application/json" + ], + "tags": [ + "catalog" + ], + "summary": "Combined catalog snapshot", + "responses": { + "200": { + "description": "Snapshot of tracks, cars and summary counters", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/clans": { + "get": { + "description": "Returns clans ordered by tag. Limit 1..200, default 50.", + "produces": [ + "application/json" + ], + "tags": [ + "clans" + ], + "summary": "List clans", + "parameters": [ + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Page of clans", + "schema": { + "$ref": "#/definitions/transport.ClanListResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "Creates a new clan. ` + "`" + `tag` + "`" + ` must be exactly 3 uppercase ASCII letters (e.g. \"ACE\") and unique.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "clans" + ], + "summary": "Create a clan", + "parameters": [ + { + "description": "Clan to create", + "name": "clan", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/transport.ClanCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created clan", + "schema": { + "$ref": "#/definitions/transport.Clan" + } + }, + "400": { + "description": "Invalid input (tag must be 3 uppercase letters)", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Clan with that tag already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/clans/{id}": { + "get": { + "description": "Dispatches on HTTP method.", + "produces": [ + "application/json" + ], + "tags": [ + "clans" + ], + "summary": "Get / update / delete a clan by id", + "parameters": [ + { + "type": "string", + "description": "Clan id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "clan", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.ClanUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Clan payload or update result", + "schema": { + "$ref": "#/definitions/transport.Clan" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.Clan" + } + }, + "400": { + "description": "Bad request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Clan not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "description": "Dispatches on HTTP method.", + "produces": [ + "application/json" + ], + "tags": [ + "clans" + ], + "summary": "Get / update / delete a clan by id", + "parameters": [ + { + "type": "string", + "description": "Clan id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "clan", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.ClanUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Clan payload or update result", + "schema": { + "$ref": "#/definitions/transport.Clan" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.Clan" + } + }, + "400": { + "description": "Bad request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Clan not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "Dispatches on HTTP method.", + "produces": [ + "application/json" + ], + "tags": [ + "clans" + ], + "summary": "Get / update / delete a clan by id", + "parameters": [ + { + "type": "string", + "description": "Clan id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "clan", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.ClanUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Clan payload or update result", + "schema": { + "$ref": "#/definitions/transport.Clan" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.Clan" + } + }, + "400": { + "description": "Bad request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Clan not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/drivers": { + "get": { + "description": "Returns drivers ordered by nickname. Optional ` + "`" + `clan_id` + "`" + ` filter.", + "produces": [ + "application/json" + ], + "tags": [ + "drivers" + ], + "summary": "List drivers", + "parameters": [ + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + }, + { + "type": "string", + "description": "Filter by clan id", + "name": "clan_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Page of drivers", + "schema": { + "$ref": "#/definitions/transport.DriverListResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "Creates a new driver. ` + "`" + `nickname` + "`" + ` must be exactly 3 uppercase ASCII letters (e.g. \"ACE\") and unique. ` + "`" + `clan_id` + "`" + ` is optional and must reference an existing clan.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "drivers" + ], + "summary": "Create a driver", + "parameters": [ + { + "description": "Driver to create", + "name": "driver", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/transport.DriverCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created driver", + "schema": { + "$ref": "#/definitions/transport.Driver" + } + }, + "400": { + "description": "Invalid input (nickname must be 3 uppercase letters)", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Driver with that nickname already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/drivers/{id}": { + "get": { + "description": "Dispatches on HTTP method. Path ` + "`" + `/api/drivers/by-nick/{nick}` + "`" + ` is a convenience that returns the driver by 3-letter nickname.", + "produces": [ + "application/json" + ], + "tags": [ + "drivers" + ], + "summary": "Get / update / delete a driver by id", + "parameters": [ + { + "type": "string", + "description": "Driver id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "driver", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.DriverUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Driver payload or update result", + "schema": { + "$ref": "#/definitions/transport.Driver" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.Driver" + } + }, + "400": { + "description": "Bad request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Driver not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "description": "Dispatches on HTTP method. Path ` + "`" + `/api/drivers/by-nick/{nick}` + "`" + ` is a convenience that returns the driver by 3-letter nickname.", + "produces": [ + "application/json" + ], + "tags": [ + "drivers" + ], + "summary": "Get / update / delete a driver by id", + "parameters": [ + { + "type": "string", + "description": "Driver id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "driver", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.DriverUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Driver payload or update result", + "schema": { + "$ref": "#/definitions/transport.Driver" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.Driver" + } + }, + "400": { + "description": "Bad request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Driver not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "Dispatches on HTTP method. Path ` + "`" + `/api/drivers/by-nick/{nick}` + "`" + ` is a convenience that returns the driver by 3-letter nickname.", + "produces": [ + "application/json" + ], + "tags": [ + "drivers" + ], + "summary": "Get / update / delete a driver by id", + "parameters": [ + { + "type": "string", + "description": "Driver id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "driver", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.DriverUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Driver payload or update result", + "schema": { + "$ref": "#/definitions/transport.Driver" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.Driver" + } + }, + "400": { + "description": "Bad request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Driver not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/races": { + "get": { + "description": "Returns one keyset page of races. Live races (status lobby|racing) come from in-memory lobby.Service; finished races come from Postgres. The two sources are merged and ordered by (started_ms DESC, id ASC). The cursor is opaque: pass back the next_cursor verbatim from a previous page.\n## Filters\n- ` + "`" + `status` + "`" + ` (comma-separated, e.g. \"racing,finished\"). Recognised values: all, lobby, racing, finished.\n- ` + "`" + `track_id` + "`" + ` (exact match against a physical track id; custom lobbies are not supported in this build).\n- ` + "`" + `limit` + "`" + ` (1..200, default 50)", + "produces": [ + "application/json" + ], + "tags": [ + "races" + ], + "summary": "List races (keyset paginated)", + "parameters": [ + { + "enum": [ + "all", + "lobby", + "racing", + "finished" + ], + "type": "string", + "description": "Comma-separated status filter", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Filter by physical track id", + "name": "track_id", + "in": "query" + }, + { + "type": "string", + "description": "Opaque keyset cursor (next_cursor from previous page)", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (1..200, default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Page of races", + "schema": { + "$ref": "#/definitions/transport.RaceListResponse" + } + }, + "400": { + "description": "Bad request / invalid cursor / invalid status", + "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.", + "produces": [ + "application/json" + ], + "tags": [ + "plans" + ], + "summary": "List race plans (keyset paginated)", + "parameters": [ + { + "type": "string", + "description": "Opaque keyset cursor", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Page of plans", + "schema": { + "$ref": "#/definitions/transport.RacePlanListResponse" + } + }, + "400": { + "description": "Invalid cursor", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "Creates a recurring or one-off scheduled race. The scheduler materialises a lobby race when ` + "`" + `next_fire_ms` + "`" + ` \u003c= now. If ` + "`" + `interval_s` + "`" + ` \u003e 0 the plan repeats; if ` + "`" + `count` + "`" + ` \u003e 0 the plan disables itself after that many fires.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "plans" + ], + "summary": "Create a race plan", + "parameters": [ + { + "description": "Plan to create", + "name": "plan", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/transport.RacePlanCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created plan", + "schema": { + "$ref": "#/definitions/transport.RacePlan" + } + }, + "400": { + "description": "Invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Plan id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/races/plans/{id}": { + "delete": { + "description": "Removes a plan by id. Materialised races already in the lobby are not affected.", + "produces": [ + "application/json" + ], + "tags": [ + "plans" + ], + "summary": "Delete a race plan", + "parameters": [ + { + "type": "string", + "description": "Plan id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Plan not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/races/queue": { + "get": { + "description": "Returns the queue entries for a driver, oldest first.", + "produces": [ + "application/json" + ], + "tags": [ + "races" + ], + "summary": "List driver's queue entries", + "parameters": [ + { + "type": "string", + "description": "Driver id", + "name": "X-Driver-Id", + "in": "header" + }, + { + "type": "string", + "description": "Driver id (alternative to header)", + "name": "driver_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Queue entries", + "schema": { + "$ref": "#/definitions/transport.RaceQueueListResponse" + } + }, + "400": { + "description": "driver_id required", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "Removes a single (driver, race) entry from the queue. Does not detach the driver from the race if already joined.", + "produces": [ + "application/json" + ], + "tags": [ + "races" + ], + "summary": "Leave a queue entry", + "parameters": [ + { + "type": "string", + "description": "Driver id", + "name": "X-Driver-Id", + "in": "header" + }, + { + "type": "string", + "description": "Driver id", + "name": "driver_id", + "in": "query" + }, + { + "type": "string", + "description": "Race id", + "name": "race_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "driver_id and race_id required", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "queue entry not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/races/queue/join": { + "post": { + "description": "Puts the driver in the queue for each of the next N (default 3) lobby|countdown races, soonest first. If a race has a free slot, the driver is also attached to it (best effort). Idempotent per (driver, race).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "races" + ], + "summary": "Enqueue driver on next N upcoming races", + "parameters": [ + { + "type": "string", + "description": "Driver id (alternative to body)", + "name": "X-Driver-Id", + "in": "header" + }, + { + "description": "Driver id and optional limit", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.RaceQueueJoinRequest" + } + } + ], + "responses": { + "200": { + "description": "Queue entries created", + "schema": { + "$ref": "#/definitions/transport.RaceQueueJoinResponse" + } + }, + "400": { + "description": "driver_id required", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/races/upcoming": { + "get": { + "description": "Returns up to ` + "`" + `limit` + "`" + ` races currently in status lobby|countdown, ordered soonest first. Useful for the \"join queue\" UI and dashboard.", + "produces": [ + "application/json" + ], + "tags": [ + "races" + ], + "summary": "Next N upcoming races", + "parameters": [ + { + "type": "integer", + "description": "Number of races to return (1..16, default 3)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Upcoming races", + "schema": { + "$ref": "#/definitions/transport.RaceUpcomingResponse" + } + }, + "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` + "`" + `.", + "produces": [ + "application/json" + ], + "tags": [ + "tracks" + ], + "summary": "List tracks", + "parameters": [ + { + "enum": [ + "system", + "public", + "private" + ], + "type": "string", + "description": "Filter by visibility", + "name": "scope", + "in": "query" + }, + { + "type": "string", + "description": "Filter by tag (case-insensitive)", + "name": "tag", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of tracks with count", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "Creates a new track. The server fills ` + "`" + `id` + "`" + ` if the client sends an empty one. ` + "`" + `author_id` + "`" + ` should be the caller's id in production (auth is disabled in PoC).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tracks" + ], + "summary": "Create a track", + "parameters": [ + { + "description": "Track to create", + "name": "track", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/transport.TrackCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/transport.TrackAck" + } + }, + "400": { + "description": "Invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Track id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/tracks/{id}": { + "get": { + "description": "Dispatches on HTTP method. The id is taken from the URL path (` + "`" + `/api/tracks/{id}` + "`" + `). System tracks are immutable and cannot be updated or deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "tracks" + ], + "summary": "Get / update / delete a track by id", + "parameters": [ + { + "type": "string", + "description": "Track id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "track", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.TrackUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Track payload (GET) or update ack (PUT)", + "schema": { + "$ref": "#/definitions/transport.TrackWire" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.TrackAck" + } + }, + "400": { + "description": "Bad request / invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "403": { + "description": "System track is immutable", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Track not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Track id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "description": "Dispatches on HTTP method. The id is taken from the URL path (` + "`" + `/api/tracks/{id}` + "`" + `). System tracks are immutable and cannot be updated or deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "tracks" + ], + "summary": "Get / update / delete a track by id", + "parameters": [ + { + "type": "string", + "description": "Track id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "track", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.TrackUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Track payload (GET) or update ack (PUT)", + "schema": { + "$ref": "#/definitions/transport.TrackWire" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.TrackAck" + } + }, + "400": { + "description": "Bad request / invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "403": { + "description": "System track is immutable", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Track not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Track id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "Dispatches on HTTP method. The id is taken from the URL path (` + "`" + `/api/tracks/{id}` + "`" + `). System tracks are immutable and cannot be updated or deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "tracks" + ], + "summary": "Get / update / delete a track by id", + "parameters": [ + { + "type": "string", + "description": "Track id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "track", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.TrackUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Track payload (GET) or update ack (PUT)", + "schema": { + "$ref": "#/definitions/transport.TrackWire" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.TrackAck" + } + }, + "400": { + "description": "Bad request / invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "403": { + "description": "System track is immutable", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Track not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Track id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/health": { + "get": { + "description": "Returns 200 OK with current engine phase, WS connection count and total snapshot drops. Intended for load-balancer health checks.", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Liveness probe", + "responses": { + "200": { + "description": "Server is up", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/stats": { + "get": { + "description": "Returns the current race phase plus realtime counters: active WS connections and total snapshots dropped due to slow consumers.", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Realtime runtime stats", + "responses": { + "200": { + "description": "Runtime counters", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/ws": { + "get": { + "description": "Opens a bidirectional WebSocket for the realtime race protocol. The server replies with ` + "`" + `server_hello` + "`" + `, then broadcasts ` + "`" + `race_snapshot` + "`" + ` frames at ` + "`" + `X0GP_SNAPSHOT_HZ` + "`" + `.\n## Frame format\nEvery frame is a JSON envelope of the form:\n` + "`" + `` + "`" + `` + "`" + `json\n{ \"type\": \"\u003cmessage_type\u003e\", \"seq\": 42, \"ts_ms\": 1700000000000, \"payload\": { ... } }\n` + "`" + `` + "`" + `` + "`" + `\n` + "`" + `type` + "`" + ` discriminates the payload; ` + "`" + `payload` + "`" + ` is documented per message type in the schemas listed in ` + "`" + `components/schemas` + "`" + ` (see ` + "`" + `Envelope` + "`" + `, ` + "`" + `ClientHello` + "`" + `, ` + "`" + `ServerHello` + "`" + `, ` + "`" + `InputState` + "`" + `, ` + "`" + `JoinRace` + "`" + `, ` + "`" + `LeaveRace` + "`" + `, ` + "`" + `RaceSnapshot` + "`" + `, ` + "`" + `InputAck` + "`" + `, ` + "`" + `RaceEvent` + "`" + `, ` + "`" + `Ping` + "`" + `, ` + "`" + `Pong` + "`" + `, ` + "`" + `ErrorMsg` + "`" + `, plus the catalog/lobby/stats envelopes).\n## Client -\u003e Server\n- ` + "`" + `client_hello` + "`" + ` — first frame after connect. Sent once.\n- ` + "`" + `join_race` + "`" + ` / ` + "`" + `leave_race` + "`" + ` — join or leave the race session.\n- ` + "`" + `input_state` + "`" + ` — high-rate control (steering/throttle/brake/gear). Send at 60-120 Hz.\n- ` + "`" + `ping` + "`" + ` — round-trip time probe.\n- ` + "`" + `track_*` + "`" + ` / ` + "`" + `car_*` + "`" + ` — catalog CRUD (mirror of the REST /api/* endpoints).\n- ` + "`" + `lobby_*` + "`" + ` — lobby management.\n- ` + "`" + `stats_request` + "`" + ` — ask for a stats snapshot.\n## Server -\u003e Client\n- ` + "`" + `server_hello` + "`" + ` — handshake reply; includes session id and engine config.\n- ` + "`" + `race_snapshot` + "`" + ` — periodic authoritative state broadcast.\n- ` + "`" + `input_ack` + "`" + ` — per-input acknowledgement (optional; drop-tolerant).\n- ` + "`" + `race_event` + "`" + ` — start/lap/sector/dnf/finish.\n- ` + "`" + `track_snapshot` + "`" + ` / ` + "`" + `car_snapshot` + "`" + ` / ` + "`" + `catalog_summary` + "`" + ` — catalog feeds on change.\n- ` + "`" + `lobby_snapshot` + "`" + ` — full lobby state on change.\n- ` + "`" + `stats_snapshot` + "`" + ` / ` + "`" + `race_standings` + "`" + ` / ` + "`" + `lap_recorded` + "`" + ` / ` + "`" + `race_result` + "`" + ` — stats feeds.\n- ` + "`" + `error` + "`" + ` — protocol or validation error.\n## Notes\n- Origin checks are disabled in PoC mode (` + "`" + `CheckOrigin: always-allow` + "`" + `).\n- Ping interval: 20 s. Pong wait: 60 s. Max frame size: 8 KiB.\n- Input acks are best-effort and silently dropped under load.", + "tags": [ + "websocket" + ], + "summary": "WebSocket endpoint (binary JSON envelope protocol)", + "responses": { + "101": { + "description": "Switching protocols. Subsequent frames follow the envelope schema.", + "schema": { + "$ref": "#/definitions/transport.Envelope" + } + } + } + } + } + }, + "definitions": { + "transport.BatteryWire": { + "type": "object", + "properties": { + "capacity_mah": { + "type": "integer" + }, + "cells": { + "type": "integer" + }, + "chemistry": { + "type": "string" + }, + "voltage_v": { + "type": "number" + } + } + }, + "transport.CarAck": { + "type": "object", + "properties": { + "car": { + "$ref": "#/definitions/transport.CarWire" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "ok": { + "type": "boolean" + } + } + }, + "transport.CarCreateRequest": { + "type": "object", + "properties": { + "car": { + "$ref": "#/definitions/transport.CarWire" + } + } + }, + "transport.CarPatch": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "avatar_url": { + "type": "string" + }, + "battery": { + "$ref": "#/definitions/transport.BatteryWire" + }, + "chassis": { + "$ref": "#/definitions/transport.ChassisWire" + }, + "color_hex": { + "type": "string" + }, + "drive": { + "type": "string" + }, + "height_mm": { + "type": "number" + }, + "length_mm": { + "type": "number" + }, + "motor": { + "$ref": "#/definitions/transport.MotorWire" + }, + "name": { + "type": "string" + }, + "scale": { + "type": "integer" + }, + "top_speed_ms": { + "type": "number" + }, + "visibility": { + "type": "string" + }, + "weight_g": { + "type": "number" + }, + "width_mm": { + "type": "number" + } + } + }, + "transport.CarUpdateRequest": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "patch": { + "$ref": "#/definitions/transport.CarPatch" + } + } + }, + "transport.CarWire": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "avatar_url": { + "type": "string" + }, + "battery": { + "$ref": "#/definitions/transport.BatteryWire" + }, + "best_lap_ms": { + "type": "integer" + }, + "chassis": { + "$ref": "#/definitions/transport.ChassisWire" + }, + "color_hex": { + "type": "string" + }, + "created_ms": { + "type": "integer" + }, + "drive": { + "type": "string" + }, + "height_mm": { + "type": "number" + }, + "id": { + "type": "string" + }, + "length_mm": { + "type": "number" + }, + "motor": { + "$ref": "#/definitions/transport.MotorWire" + }, + "name": { + "type": "string" + }, + "owner_id": { + "type": "string" + }, + "scale": { + "type": "integer" + }, + "top_speed_ms": { + "type": "number" + }, + "total_distance_m": { + "type": "number" + }, + "total_laps": { + "type": "integer" + }, + "total_races": { + "type": "integer" + }, + "updated_ms": { + "type": "integer" + }, + "visibility": { + "type": "string" + }, + "weight_g": { + "type": "number" + }, + "width_mm": { + "type": "number" + } + } + }, + "transport.ChassisWire": { + "type": "object", + "properties": { + "material": { + "type": "string" + }, + "model": { + "type": "string" + }, + "printed": { + "type": "boolean" + }, + "track_mm": { + "type": "number" + }, + "wheelbase_mm": { + "type": "number" + } + } + }, + "transport.Clan": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "example": "https://cdn.example.com/c/ace.png" + }, + "created_ms": { + "type": "integer" + }, + "id": { + "type": "string", + "example": "clan-1782000-1" + }, + "name": { + "type": "string", + "example": "Ace Racing" + }, + "tag": { + "type": "string", + "example": "ACE" + }, + "updated_ms": { + "type": "integer" + } + } + }, + "transport.ClanCreateRequest": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "example": "https://cdn.example.com/c/ace.png" + }, + "id": { + "type": "string", + "example": "clan-1782000-1" + }, + "name": { + "type": "string", + "example": "Ace Racing" + }, + "tag": { + "type": "string", + "example": "ACE" + } + } + }, + "transport.ClanListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.Clan" + } + } + } + }, + "transport.ClanUpdateRequest": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "example": "https://cdn.example.com/c/ace.png" + }, + "name": { + "type": "string", + "example": "Ace Racing" + } + } + }, + "transport.Driver": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "example": "https://cdn.example.com/u/ace.png" + }, + "clan_id": { + "type": "string", + "example": "clan-1782000-1" + }, + "created_ms": { + "type": "integer" + }, + "id": { + "type": "string", + "example": "driver-1782000-123" + }, + "name": { + "type": "string", + "example": "Alice" + }, + "nickname": { + "type": "string", + "example": "ACE" + }, + "updated_ms": { + "type": "integer" + } + } + }, + "transport.DriverCreateRequest": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "example": "https://cdn.example.com/u/ace.png" + }, + "clan_id": { + "type": "string", + "example": "clan-1782000-1" + }, + "id": { + "type": "string", + "example": "driver-1782000-123" + }, + "name": { + "type": "string", + "example": "Alice" + }, + "nickname": { + "type": "string", + "example": "ACE" + } + } + }, + "transport.DriverListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.Driver" + } + } + } + }, + "transport.DriverUpdateRequest": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "example": "https://cdn.example.com/u/ace.png" + }, + "clan_id": { + "type": "string", + "example": "clan-1782000-1" + }, + "name": { + "type": "string", + "example": "Alice" + } + } + }, + "transport.Envelope": { + "type": "object", + "properties": { + "payload": {}, + "seq": { + "type": "integer" + }, + "ts_ms": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/transport.MessageType" + } + } + }, + "transport.LobbyRace": { + "type": "object", + "properties": { + "created_ms": { + "type": "integer" + }, + "driver_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "laps": { + "type": "integer" + }, + "max_cars": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "podium": { + "description": "Podium is populated only for finished races. Empty for live ones.", + "type": "array", + "items": { + "$ref": "#/definitions/transport.RacePodiumEntry" + } + }, + "started_ms": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "time_limit_s": { + "type": "integer" + }, + "track_id": { + "type": "string" + } + } + }, + "transport.MessageType": { + "type": "string", + "enum": [ + "client_hello", + "join_race", + "leave_race", + "input_state", + "chat_message", + "ping", + "lobby_list", + "lobby_create", + "lobby_quick_play", + "lobby_join_race", + "lobby_leave_race", + "lobby_kick_driver", + "lobby_delete", + "track_list", + "track_get", + "track_create", + "track_update", + "track_delete", + "car_list", + "car_get", + "car_create", + "car_update", + "car_delete", + "stats_request", + "server_hello", + "race_snapshot", + "input_ack", + "correction", + "race_event", + "pong", + "error", + "lobby_snapshot", + "track_snapshot", + "track_ack", + "car_snapshot", + "car_ack", + "catalog_summary", + "stats_snapshot", + "driver_profile", + "lap_recorded", + "race_result", + "race_standings" + ], + "x-enum-varnames": [ + "TypeClientHello", + "TypeJoinRace", + "TypeLeaveRace", + "TypeInputState", + "TypeChatMessage", + "TypePing", + "TypeLobbyList", + "TypeLobbyCreate", + "TypeLobbyQuickPlay", + "TypeLobbyJoinRace", + "TypeLobbyLeaveRace", + "TypeLobbyKickDriver", + "TypeLobbyDelete", + "TypeTrackList", + "TypeTrackGet", + "TypeTrackCreate", + "TypeTrackUpdate", + "TypeTrackDelete", + "TypeCarList", + "TypeCarGet", + "TypeCarCreate", + "TypeCarUpdate", + "TypeCarDelete", + "TypeStatsRequest", + "TypeServerHello", + "TypeRaceSnapshot", + "TypeInputAck", + "TypeCorrection", + "TypeRaceEvent", + "TypePong", + "TypeError", + "TypeLobbySnapshot", + "TypeTrackSnapshot", + "TypeTrackAck", + "TypeCarSnapshot", + "TypeCarAck", + "TypeCatalogSummary", + "TypeStatsSnapshot", + "TypeDriverProfile", + "TypeLapRecorded", + "TypeRaceResult", + "TypeRaceStandings" + ] + }, + "transport.MotorWire": { + "type": "object", + "properties": { + "class": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "kv": { + "type": "integer" + }, + "power_w": { + "type": "number" + } + } + }, + "transport.RaceListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.LobbyRace" + } + }, + "next_cursor": { + "type": "string", + "example": "MTcwMDAwMDAwMDAwMDpyYWNlLWFiYzpmaW5pc2hlZA" + } + } + }, + "transport.RacePlan": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 0 + }, + "created_ms": { + "type": "integer" + }, + "enabled": { + "type": "boolean", + "example": true + }, + "fires_done": { + "type": "integer" + }, + "id": { + "type": "string", + "example": "plan-1730000-1" + }, + "interval_s": { + "type": "integer", + "example": 3600 + }, + "laps": { + "type": "integer", + "example": 10 + }, + "max_cars": { + "type": "integer", + "example": 4 + }, + "name": { + "type": "string", + "example": "Daily Monza" + }, + "next_fire_ms": { + "type": "integer" + }, + "start_at_ms": { + "type": "integer", + "example": 1730000000000 + }, + "time_limit_s": { + "type": "integer" + }, + "track_id": { + "type": "string", + "example": "default" + }, + "updated_ms": { + "type": "integer" + } + } + }, + "transport.RacePlanCreateRequest": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 0 + }, + "enabled": { + "type": "boolean", + "example": true + }, + "id": { + "type": "string", + "example": "plan-1730000-1" + }, + "interval_s": { + "type": "integer", + "example": 3600 + }, + "laps": { + "type": "integer", + "example": 10 + }, + "max_cars": { + "type": "integer", + "example": 4 + }, + "name": { + "type": "string", + "example": "Daily Monza" + }, + "start_at_ms": { + "type": "integer", + "example": 1730000000000 + }, + "time_limit_s": { + "type": "integer" + }, + "track_id": { + "type": "string", + "example": "default" + } + } + }, + "transport.RacePlanListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.RacePlan" + } + }, + "next_cursor": { + "type": "string" + } + } + }, + "transport.RacePodiumEntry": { + "type": "object", + "properties": { + "driver_id": { + "type": "string", + "example": "driver-alice" + }, + "name": { + "type": "string", + "example": "driver-alice" + }, + "position": { + "type": "integer", + "example": 1 + }, + "total_time_ms": { + "type": "integer", + "example": 123456 + } + } + }, + "transport.RaceQueueEntry": { + "type": "object", + "properties": { + "driver_id": { + "type": "string", + "example": "driver-42" + }, + "enqueued_ms": { + "type": "integer", + "example": 1730000000000 + }, + "plan_id": { + "type": "string", + "example": "plan-1730000-1" + }, + "race_id": { + "type": "string", + "example": "race-1730000-7" + } + } + }, + "transport.RaceQueueJoinRequest": { + "type": "object", + "properties": { + "driver_id": { + "type": "string", + "example": "driver-42" + }, + "limit": { + "type": "integer", + "example": 3 + } + } + }, + "transport.RaceQueueJoinResponse": { + "type": "object", + "properties": { + "joined": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.RaceQueueEntry" + } + }, + "skipped": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "race-1", + "race-2" + ] + } + } + }, + "transport.RaceQueueListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.RaceQueueEntry" + } + } + } + }, + "transport.RaceUpcomingItem": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/definitions/transport.LobbyRace" + }, + "plan_id": { + "type": "string" + }, + "queue_len": { + "type": "integer" + }, + "start_at_ms": { + "type": "integer" + } + } + }, + "transport.RaceUpcomingResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.RaceUpcomingItem" + } + } + } + }, + "transport.TrackAck": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "track": { + "$ref": "#/definitions/transport.TrackWire" + } + } + }, + "transport.TrackBounds": { + "type": "object", + "properties": { + "length_m": { + "type": "number" + }, + "max_x": { + "type": "number" + }, + "max_y": { + "type": "number" + }, + "min_x": { + "type": "number" + }, + "min_y": { + "type": "number" + }, + "width_m": { + "type": "number" + } + } + }, + "transport.TrackCreateRequest": { + "type": "object", + "properties": { + "track": { + "$ref": "#/definitions/transport.TrackWire" + } + } + }, + "transport.TrackPatch": { + "type": "object", + "properties": { + "centerline": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.TrackWaypoint" + } + }, + "description": { + "type": "string" + }, + "lane_width_m": { + "type": "number" + }, + "length_m": { + "type": "number" + }, + "name": { + "type": "string" + }, + "scale": { + "type": "integer" + }, + "surface": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "visibility": { + "type": "string" + }, + "width_m": { + "type": "number" + } + } + }, + "transport.TrackUpdateRequest": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "patch": { + "$ref": "#/definitions/transport.TrackPatch" + } + } + }, + "transport.TrackWaypoint": { + "type": "object", + "properties": { + "curvature": { + "type": "number" + }, + "heading_rad": { + "type": "number" + }, + "speed_ms": { + "type": "number" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + }, + "transport.TrackWire": { + "type": "object", + "properties": { + "author_id": { + "type": "string" + }, + "best_lap_holder": { + "type": "string" + }, + "best_lap_ms": { + "type": "integer" + }, + "bounds": { + "$ref": "#/definitions/transport.TrackBounds" + }, + "centerline": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.TrackWaypoint" + } + }, + "created_ms": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lane_width_m": { + "type": "number" + }, + "length_m": { + "type": "number" + }, + "name": { + "type": "string" + }, + "scale": { + "type": "integer" + }, + "surface": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "updated_ms": { + "type": "integer" + }, + "visibility": { + "type": "string" + }, + "width_m": { + "type": "number" + } + } + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "0.1.0-poc", + Host: "localhost:8080", + BasePath: "/", + Schemes: []string{"http", "ws"}, + Title: "x0gp PoC server API", + Description: "Minimal HTTP+WebSocket server for the x0gp RC racing PoC. REST endpoints under /api/* manage the track/car catalog; /health and /stats expose server liveness and realtime metrics; /ws carries the realtime race protocol.\nAuth is intentionally disabled in PoC mode (X0GP_DEV_MODE=true). All catalog mutations are allowed without a JWT.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/server/docs/swagger.json b/server/docs/swagger.json new file mode 100644 index 0000000..dddc91b --- /dev/null +++ b/server/docs/swagger.json @@ -0,0 +1,2714 @@ +{ + "schemes": [ + "http", + "ws" + ], + "swagger": "2.0", + "info": { + "description": "Minimal HTTP+WebSocket server for the x0gp RC racing PoC. REST endpoints under /api/* manage the track/car catalog; /health and /stats expose server liveness and realtime metrics; /ws carries the realtime race protocol.\nAuth is intentionally disabled in PoC mode (X0GP_DEV_MODE=true). All catalog mutations are allowed without a JWT.", + "title": "x0gp PoC server API", + "contact": { + "name": "x0gp" + }, + "license": { + "name": "MIT" + }, + "version": "0.1.0-poc" + }, + "host": "localhost:8080", + "basePath": "/", + "paths": { + "/api/cars": { + "get": { + "description": "Returns all cars visible to the caller. Optional query filters: `scope=system|public|private`, `owner_id=\u003cstring\u003e`.", + "produces": [ + "application/json" + ], + "tags": [ + "cars" + ], + "summary": "List cars", + "parameters": [ + { + "enum": [ + "system", + "public", + "private" + ], + "type": "string", + "description": "Filter by visibility", + "name": "scope", + "in": "query" + }, + { + "type": "string", + "description": "Filter by owner id", + "name": "owner_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of cars with count", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "Creates a new car. The server fills `id` if the client sends an empty one. `owner_id` should be the caller's id in production (auth is disabled in PoC).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cars" + ], + "summary": "Create a car", + "parameters": [ + { + "description": "Car to create", + "name": "car", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/transport.CarCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/transport.CarAck" + } + }, + "400": { + "description": "Invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Car id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/cars/{id}": { + "get": { + "description": "Dispatches on HTTP method. The id is taken from the URL path (`/api/cars/{id}`). System cars are immutable and cannot be updated or deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "cars" + ], + "summary": "Get / update / delete a car by id", + "parameters": [ + { + "type": "string", + "description": "Car id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "car", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.CarUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Car payload (GET) or update ack (PUT)", + "schema": { + "$ref": "#/definitions/transport.CarWire" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.CarAck" + } + }, + "400": { + "description": "Bad request / invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "403": { + "description": "System car is immutable", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Car not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Car id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "description": "Dispatches on HTTP method. The id is taken from the URL path (`/api/cars/{id}`). System cars are immutable and cannot be updated or deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "cars" + ], + "summary": "Get / update / delete a car by id", + "parameters": [ + { + "type": "string", + "description": "Car id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "car", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.CarUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Car payload (GET) or update ack (PUT)", + "schema": { + "$ref": "#/definitions/transport.CarWire" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.CarAck" + } + }, + "400": { + "description": "Bad request / invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "403": { + "description": "System car is immutable", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Car not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Car id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "Dispatches on HTTP method. The id is taken from the URL path (`/api/cars/{id}`). System cars are immutable and cannot be updated or deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "cars" + ], + "summary": "Get / update / delete a car by id", + "parameters": [ + { + "type": "string", + "description": "Car id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "car", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.CarUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Car payload (GET) or update ack (PUT)", + "schema": { + "$ref": "#/definitions/transport.CarWire" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.CarAck" + } + }, + "400": { + "description": "Bad request / invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "403": { + "description": "System car is immutable", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Car not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Car id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/catalog": { + "get": { + "description": "Returns the full in-memory catalog: all tracks, all cars, and aggregate counters. This is the cheapest way for a UI to bootstrap its initial state.", + "produces": [ + "application/json" + ], + "tags": [ + "catalog" + ], + "summary": "Combined catalog snapshot", + "responses": { + "200": { + "description": "Snapshot of tracks, cars and summary counters", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/clans": { + "get": { + "description": "Returns clans ordered by tag. Limit 1..200, default 50.", + "produces": [ + "application/json" + ], + "tags": [ + "clans" + ], + "summary": "List clans", + "parameters": [ + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Page of clans", + "schema": { + "$ref": "#/definitions/transport.ClanListResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "Creates a new clan. `tag` must be exactly 3 uppercase ASCII letters (e.g. \"ACE\") and unique.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "clans" + ], + "summary": "Create a clan", + "parameters": [ + { + "description": "Clan to create", + "name": "clan", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/transport.ClanCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created clan", + "schema": { + "$ref": "#/definitions/transport.Clan" + } + }, + "400": { + "description": "Invalid input (tag must be 3 uppercase letters)", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Clan with that tag already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/clans/{id}": { + "get": { + "description": "Dispatches on HTTP method.", + "produces": [ + "application/json" + ], + "tags": [ + "clans" + ], + "summary": "Get / update / delete a clan by id", + "parameters": [ + { + "type": "string", + "description": "Clan id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "clan", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.ClanUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Clan payload or update result", + "schema": { + "$ref": "#/definitions/transport.Clan" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.Clan" + } + }, + "400": { + "description": "Bad request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Clan not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "description": "Dispatches on HTTP method.", + "produces": [ + "application/json" + ], + "tags": [ + "clans" + ], + "summary": "Get / update / delete a clan by id", + "parameters": [ + { + "type": "string", + "description": "Clan id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "clan", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.ClanUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Clan payload or update result", + "schema": { + "$ref": "#/definitions/transport.Clan" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.Clan" + } + }, + "400": { + "description": "Bad request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Clan not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "Dispatches on HTTP method.", + "produces": [ + "application/json" + ], + "tags": [ + "clans" + ], + "summary": "Get / update / delete a clan by id", + "parameters": [ + { + "type": "string", + "description": "Clan id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "clan", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.ClanUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Clan payload or update result", + "schema": { + "$ref": "#/definitions/transport.Clan" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.Clan" + } + }, + "400": { + "description": "Bad request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Clan not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/drivers": { + "get": { + "description": "Returns drivers ordered by nickname. Optional `clan_id` filter.", + "produces": [ + "application/json" + ], + "tags": [ + "drivers" + ], + "summary": "List drivers", + "parameters": [ + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + }, + { + "type": "string", + "description": "Filter by clan id", + "name": "clan_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Page of drivers", + "schema": { + "$ref": "#/definitions/transport.DriverListResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "Creates a new driver. `nickname` must be exactly 3 uppercase ASCII letters (e.g. \"ACE\") and unique. `clan_id` is optional and must reference an existing clan.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "drivers" + ], + "summary": "Create a driver", + "parameters": [ + { + "description": "Driver to create", + "name": "driver", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/transport.DriverCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created driver", + "schema": { + "$ref": "#/definitions/transport.Driver" + } + }, + "400": { + "description": "Invalid input (nickname must be 3 uppercase letters)", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Driver with that nickname already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/drivers/{id}": { + "get": { + "description": "Dispatches on HTTP method. Path `/api/drivers/by-nick/{nick}` is a convenience that returns the driver by 3-letter nickname.", + "produces": [ + "application/json" + ], + "tags": [ + "drivers" + ], + "summary": "Get / update / delete a driver by id", + "parameters": [ + { + "type": "string", + "description": "Driver id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "driver", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.DriverUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Driver payload or update result", + "schema": { + "$ref": "#/definitions/transport.Driver" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.Driver" + } + }, + "400": { + "description": "Bad request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Driver not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "description": "Dispatches on HTTP method. Path `/api/drivers/by-nick/{nick}` is a convenience that returns the driver by 3-letter nickname.", + "produces": [ + "application/json" + ], + "tags": [ + "drivers" + ], + "summary": "Get / update / delete a driver by id", + "parameters": [ + { + "type": "string", + "description": "Driver id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "driver", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.DriverUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Driver payload or update result", + "schema": { + "$ref": "#/definitions/transport.Driver" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.Driver" + } + }, + "400": { + "description": "Bad request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Driver not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "Dispatches on HTTP method. Path `/api/drivers/by-nick/{nick}` is a convenience that returns the driver by 3-letter nickname.", + "produces": [ + "application/json" + ], + "tags": [ + "drivers" + ], + "summary": "Get / update / delete a driver by id", + "parameters": [ + { + "type": "string", + "description": "Driver id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "driver", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.DriverUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Driver payload or update result", + "schema": { + "$ref": "#/definitions/transport.Driver" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.Driver" + } + }, + "400": { + "description": "Bad request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Driver not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/races": { + "get": { + "description": "Returns one keyset page of races. Live races (status lobby|racing) come from in-memory lobby.Service; finished races come from Postgres. The two sources are merged and ordered by (started_ms DESC, id ASC). The cursor is opaque: pass back the next_cursor verbatim from a previous page.\n## Filters\n- `status` (comma-separated, e.g. \"racing,finished\"). Recognised values: all, lobby, racing, finished.\n- `track_id` (exact match against a physical track id; custom lobbies are not supported in this build).\n- `limit` (1..200, default 50)", + "produces": [ + "application/json" + ], + "tags": [ + "races" + ], + "summary": "List races (keyset paginated)", + "parameters": [ + { + "enum": [ + "all", + "lobby", + "racing", + "finished" + ], + "type": "string", + "description": "Comma-separated status filter", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Filter by physical track id", + "name": "track_id", + "in": "query" + }, + { + "type": "string", + "description": "Opaque keyset cursor (next_cursor from previous page)", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (1..200, default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Page of races", + "schema": { + "$ref": "#/definitions/transport.RaceListResponse" + } + }, + "400": { + "description": "Bad request / invalid cursor / invalid status", + "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.", + "produces": [ + "application/json" + ], + "tags": [ + "plans" + ], + "summary": "List race plans (keyset paginated)", + "parameters": [ + { + "type": "string", + "description": "Opaque keyset cursor", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Page of plans", + "schema": { + "$ref": "#/definitions/transport.RacePlanListResponse" + } + }, + "400": { + "description": "Invalid cursor", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "Creates a recurring or one-off scheduled race. The scheduler materialises a lobby race when `next_fire_ms` \u003c= now. If `interval_s` \u003e 0 the plan repeats; if `count` \u003e 0 the plan disables itself after that many fires.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "plans" + ], + "summary": "Create a race plan", + "parameters": [ + { + "description": "Plan to create", + "name": "plan", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/transport.RacePlanCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created plan", + "schema": { + "$ref": "#/definitions/transport.RacePlan" + } + }, + "400": { + "description": "Invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Plan id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/races/plans/{id}": { + "delete": { + "description": "Removes a plan by id. Materialised races already in the lobby are not affected.", + "produces": [ + "application/json" + ], + "tags": [ + "plans" + ], + "summary": "Delete a race plan", + "parameters": [ + { + "type": "string", + "description": "Plan id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Plan not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/races/queue": { + "get": { + "description": "Returns the queue entries for a driver, oldest first.", + "produces": [ + "application/json" + ], + "tags": [ + "races" + ], + "summary": "List driver's queue entries", + "parameters": [ + { + "type": "string", + "description": "Driver id", + "name": "X-Driver-Id", + "in": "header" + }, + { + "type": "string", + "description": "Driver id (alternative to header)", + "name": "driver_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Queue entries", + "schema": { + "$ref": "#/definitions/transport.RaceQueueListResponse" + } + }, + "400": { + "description": "driver_id required", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "Removes a single (driver, race) entry from the queue. Does not detach the driver from the race if already joined.", + "produces": [ + "application/json" + ], + "tags": [ + "races" + ], + "summary": "Leave a queue entry", + "parameters": [ + { + "type": "string", + "description": "Driver id", + "name": "X-Driver-Id", + "in": "header" + }, + { + "type": "string", + "description": "Driver id", + "name": "driver_id", + "in": "query" + }, + { + "type": "string", + "description": "Race id", + "name": "race_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "driver_id and race_id required", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "queue entry not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/races/queue/join": { + "post": { + "description": "Puts the driver in the queue for each of the next N (default 3) lobby|countdown races, soonest first. If a race has a free slot, the driver is also attached to it (best effort). Idempotent per (driver, race).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "races" + ], + "summary": "Enqueue driver on next N upcoming races", + "parameters": [ + { + "type": "string", + "description": "Driver id (alternative to body)", + "name": "X-Driver-Id", + "in": "header" + }, + { + "description": "Driver id and optional limit", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.RaceQueueJoinRequest" + } + } + ], + "responses": { + "200": { + "description": "Queue entries created", + "schema": { + "$ref": "#/definitions/transport.RaceQueueJoinResponse" + } + }, + "400": { + "description": "driver_id required", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/races/upcoming": { + "get": { + "description": "Returns up to `limit` races currently in status lobby|countdown, ordered soonest first. Useful for the \"join queue\" UI and dashboard.", + "produces": [ + "application/json" + ], + "tags": [ + "races" + ], + "summary": "Next N upcoming races", + "parameters": [ + { + "type": "integer", + "description": "Number of races to return (1..16, default 3)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Upcoming races", + "schema": { + "$ref": "#/definitions/transport.RaceUpcomingResponse" + } + }, + "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`.", + "produces": [ + "application/json" + ], + "tags": [ + "tracks" + ], + "summary": "List tracks", + "parameters": [ + { + "enum": [ + "system", + "public", + "private" + ], + "type": "string", + "description": "Filter by visibility", + "name": "scope", + "in": "query" + }, + { + "type": "string", + "description": "Filter by tag (case-insensitive)", + "name": "tag", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of tracks with count", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "Creates a new track. The server fills `id` if the client sends an empty one. `author_id` should be the caller's id in production (auth is disabled in PoC).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tracks" + ], + "summary": "Create a track", + "parameters": [ + { + "description": "Track to create", + "name": "track", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/transport.TrackCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/transport.TrackAck" + } + }, + "400": { + "description": "Invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Track id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/tracks/{id}": { + "get": { + "description": "Dispatches on HTTP method. The id is taken from the URL path (`/api/tracks/{id}`). System tracks are immutable and cannot be updated or deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "tracks" + ], + "summary": "Get / update / delete a track by id", + "parameters": [ + { + "type": "string", + "description": "Track id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "track", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.TrackUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Track payload (GET) or update ack (PUT)", + "schema": { + "$ref": "#/definitions/transport.TrackWire" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.TrackAck" + } + }, + "400": { + "description": "Bad request / invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "403": { + "description": "System track is immutable", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Track not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Track id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "description": "Dispatches on HTTP method. The id is taken from the URL path (`/api/tracks/{id}`). System tracks are immutable and cannot be updated or deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "tracks" + ], + "summary": "Get / update / delete a track by id", + "parameters": [ + { + "type": "string", + "description": "Track id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "track", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.TrackUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Track payload (GET) or update ack (PUT)", + "schema": { + "$ref": "#/definitions/transport.TrackWire" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.TrackAck" + } + }, + "400": { + "description": "Bad request / invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "403": { + "description": "System track is immutable", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Track not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Track id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "Dispatches on HTTP method. The id is taken from the URL path (`/api/tracks/{id}`). System tracks are immutable and cannot be updated or deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "tracks" + ], + "summary": "Get / update / delete a track by id", + "parameters": [ + { + "type": "string", + "description": "Track id", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Patch payload (only for PUT)", + "name": "track", + "in": "body", + "schema": { + "$ref": "#/definitions/transport.TrackUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Track payload (GET) or update ack (PUT)", + "schema": { + "$ref": "#/definitions/transport.TrackWire" + } + }, + "204": { + "description": "Delete ack", + "schema": { + "$ref": "#/definitions/transport.TrackAck" + } + }, + "400": { + "description": "Bad request / invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "403": { + "description": "System track is immutable", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Track not found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "409": { + "description": "Track id already exists", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/health": { + "get": { + "description": "Returns 200 OK with current engine phase, WS connection count and total snapshot drops. Intended for load-balancer health checks.", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Liveness probe", + "responses": { + "200": { + "description": "Server is up", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/stats": { + "get": { + "description": "Returns the current race phase plus realtime counters: active WS connections and total snapshots dropped due to slow consumers.", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Realtime runtime stats", + "responses": { + "200": { + "description": "Runtime counters", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/ws": { + "get": { + "description": "Opens a bidirectional WebSocket for the realtime race protocol. The server replies with `server_hello`, then broadcasts `race_snapshot` frames at `X0GP_SNAPSHOT_HZ`.\n## Frame format\nEvery frame is a JSON envelope of the form:\n```json\n{ \"type\": \"\u003cmessage_type\u003e\", \"seq\": 42, \"ts_ms\": 1700000000000, \"payload\": { ... } }\n```\n`type` discriminates the payload; `payload` is documented per message type in the schemas listed in `components/schemas` (see `Envelope`, `ClientHello`, `ServerHello`, `InputState`, `JoinRace`, `LeaveRace`, `RaceSnapshot`, `InputAck`, `RaceEvent`, `Ping`, `Pong`, `ErrorMsg`, plus the catalog/lobby/stats envelopes).\n## Client -\u003e Server\n- `client_hello` — first frame after connect. Sent once.\n- `join_race` / `leave_race` — join or leave the race session.\n- `input_state` — high-rate control (steering/throttle/brake/gear). Send at 60-120 Hz.\n- `ping` — round-trip time probe.\n- `track_*` / `car_*` — catalog CRUD (mirror of the REST /api/* endpoints).\n- `lobby_*` — lobby management.\n- `stats_request` — ask for a stats snapshot.\n## Server -\u003e Client\n- `server_hello` — handshake reply; includes session id and engine config.\n- `race_snapshot` — periodic authoritative state broadcast.\n- `input_ack` — per-input acknowledgement (optional; drop-tolerant).\n- `race_event` — start/lap/sector/dnf/finish.\n- `track_snapshot` / `car_snapshot` / `catalog_summary` — catalog feeds on change.\n- `lobby_snapshot` — full lobby state on change.\n- `stats_snapshot` / `race_standings` / `lap_recorded` / `race_result` — stats feeds.\n- `error` — protocol or validation error.\n## Notes\n- Origin checks are disabled in PoC mode (`CheckOrigin: always-allow`).\n- Ping interval: 20 s. Pong wait: 60 s. Max frame size: 8 KiB.\n- Input acks are best-effort and silently dropped under load.", + "tags": [ + "websocket" + ], + "summary": "WebSocket endpoint (binary JSON envelope protocol)", + "responses": { + "101": { + "description": "Switching protocols. Subsequent frames follow the envelope schema.", + "schema": { + "$ref": "#/definitions/transport.Envelope" + } + } + } + } + } + }, + "definitions": { + "transport.BatteryWire": { + "type": "object", + "properties": { + "capacity_mah": { + "type": "integer" + }, + "cells": { + "type": "integer" + }, + "chemistry": { + "type": "string" + }, + "voltage_v": { + "type": "number" + } + } + }, + "transport.CarAck": { + "type": "object", + "properties": { + "car": { + "$ref": "#/definitions/transport.CarWire" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "ok": { + "type": "boolean" + } + } + }, + "transport.CarCreateRequest": { + "type": "object", + "properties": { + "car": { + "$ref": "#/definitions/transport.CarWire" + } + } + }, + "transport.CarPatch": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "avatar_url": { + "type": "string" + }, + "battery": { + "$ref": "#/definitions/transport.BatteryWire" + }, + "chassis": { + "$ref": "#/definitions/transport.ChassisWire" + }, + "color_hex": { + "type": "string" + }, + "drive": { + "type": "string" + }, + "height_mm": { + "type": "number" + }, + "length_mm": { + "type": "number" + }, + "motor": { + "$ref": "#/definitions/transport.MotorWire" + }, + "name": { + "type": "string" + }, + "scale": { + "type": "integer" + }, + "top_speed_ms": { + "type": "number" + }, + "visibility": { + "type": "string" + }, + "weight_g": { + "type": "number" + }, + "width_mm": { + "type": "number" + } + } + }, + "transport.CarUpdateRequest": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "patch": { + "$ref": "#/definitions/transport.CarPatch" + } + } + }, + "transport.CarWire": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "avatar_url": { + "type": "string" + }, + "battery": { + "$ref": "#/definitions/transport.BatteryWire" + }, + "best_lap_ms": { + "type": "integer" + }, + "chassis": { + "$ref": "#/definitions/transport.ChassisWire" + }, + "color_hex": { + "type": "string" + }, + "created_ms": { + "type": "integer" + }, + "drive": { + "type": "string" + }, + "height_mm": { + "type": "number" + }, + "id": { + "type": "string" + }, + "length_mm": { + "type": "number" + }, + "motor": { + "$ref": "#/definitions/transport.MotorWire" + }, + "name": { + "type": "string" + }, + "owner_id": { + "type": "string" + }, + "scale": { + "type": "integer" + }, + "top_speed_ms": { + "type": "number" + }, + "total_distance_m": { + "type": "number" + }, + "total_laps": { + "type": "integer" + }, + "total_races": { + "type": "integer" + }, + "updated_ms": { + "type": "integer" + }, + "visibility": { + "type": "string" + }, + "weight_g": { + "type": "number" + }, + "width_mm": { + "type": "number" + } + } + }, + "transport.ChassisWire": { + "type": "object", + "properties": { + "material": { + "type": "string" + }, + "model": { + "type": "string" + }, + "printed": { + "type": "boolean" + }, + "track_mm": { + "type": "number" + }, + "wheelbase_mm": { + "type": "number" + } + } + }, + "transport.Clan": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "example": "https://cdn.example.com/c/ace.png" + }, + "created_ms": { + "type": "integer" + }, + "id": { + "type": "string", + "example": "clan-1782000-1" + }, + "name": { + "type": "string", + "example": "Ace Racing" + }, + "tag": { + "type": "string", + "example": "ACE" + }, + "updated_ms": { + "type": "integer" + } + } + }, + "transport.ClanCreateRequest": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "example": "https://cdn.example.com/c/ace.png" + }, + "id": { + "type": "string", + "example": "clan-1782000-1" + }, + "name": { + "type": "string", + "example": "Ace Racing" + }, + "tag": { + "type": "string", + "example": "ACE" + } + } + }, + "transport.ClanListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.Clan" + } + } + } + }, + "transport.ClanUpdateRequest": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "example": "https://cdn.example.com/c/ace.png" + }, + "name": { + "type": "string", + "example": "Ace Racing" + } + } + }, + "transport.Driver": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "example": "https://cdn.example.com/u/ace.png" + }, + "clan_id": { + "type": "string", + "example": "clan-1782000-1" + }, + "created_ms": { + "type": "integer" + }, + "id": { + "type": "string", + "example": "driver-1782000-123" + }, + "name": { + "type": "string", + "example": "Alice" + }, + "nickname": { + "type": "string", + "example": "ACE" + }, + "updated_ms": { + "type": "integer" + } + } + }, + "transport.DriverCreateRequest": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "example": "https://cdn.example.com/u/ace.png" + }, + "clan_id": { + "type": "string", + "example": "clan-1782000-1" + }, + "id": { + "type": "string", + "example": "driver-1782000-123" + }, + "name": { + "type": "string", + "example": "Alice" + }, + "nickname": { + "type": "string", + "example": "ACE" + } + } + }, + "transport.DriverListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.Driver" + } + } + } + }, + "transport.DriverUpdateRequest": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "example": "https://cdn.example.com/u/ace.png" + }, + "clan_id": { + "type": "string", + "example": "clan-1782000-1" + }, + "name": { + "type": "string", + "example": "Alice" + } + } + }, + "transport.Envelope": { + "type": "object", + "properties": { + "payload": {}, + "seq": { + "type": "integer" + }, + "ts_ms": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/transport.MessageType" + } + } + }, + "transport.LobbyRace": { + "type": "object", + "properties": { + "created_ms": { + "type": "integer" + }, + "driver_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "laps": { + "type": "integer" + }, + "max_cars": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "podium": { + "description": "Podium is populated only for finished races. Empty for live ones.", + "type": "array", + "items": { + "$ref": "#/definitions/transport.RacePodiumEntry" + } + }, + "started_ms": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "time_limit_s": { + "type": "integer" + }, + "track_id": { + "type": "string" + } + } + }, + "transport.MessageType": { + "type": "string", + "enum": [ + "client_hello", + "join_race", + "leave_race", + "input_state", + "chat_message", + "ping", + "lobby_list", + "lobby_create", + "lobby_quick_play", + "lobby_join_race", + "lobby_leave_race", + "lobby_kick_driver", + "lobby_delete", + "track_list", + "track_get", + "track_create", + "track_update", + "track_delete", + "car_list", + "car_get", + "car_create", + "car_update", + "car_delete", + "stats_request", + "server_hello", + "race_snapshot", + "input_ack", + "correction", + "race_event", + "pong", + "error", + "lobby_snapshot", + "track_snapshot", + "track_ack", + "car_snapshot", + "car_ack", + "catalog_summary", + "stats_snapshot", + "driver_profile", + "lap_recorded", + "race_result", + "race_standings" + ], + "x-enum-varnames": [ + "TypeClientHello", + "TypeJoinRace", + "TypeLeaveRace", + "TypeInputState", + "TypeChatMessage", + "TypePing", + "TypeLobbyList", + "TypeLobbyCreate", + "TypeLobbyQuickPlay", + "TypeLobbyJoinRace", + "TypeLobbyLeaveRace", + "TypeLobbyKickDriver", + "TypeLobbyDelete", + "TypeTrackList", + "TypeTrackGet", + "TypeTrackCreate", + "TypeTrackUpdate", + "TypeTrackDelete", + "TypeCarList", + "TypeCarGet", + "TypeCarCreate", + "TypeCarUpdate", + "TypeCarDelete", + "TypeStatsRequest", + "TypeServerHello", + "TypeRaceSnapshot", + "TypeInputAck", + "TypeCorrection", + "TypeRaceEvent", + "TypePong", + "TypeError", + "TypeLobbySnapshot", + "TypeTrackSnapshot", + "TypeTrackAck", + "TypeCarSnapshot", + "TypeCarAck", + "TypeCatalogSummary", + "TypeStatsSnapshot", + "TypeDriverProfile", + "TypeLapRecorded", + "TypeRaceResult", + "TypeRaceStandings" + ] + }, + "transport.MotorWire": { + "type": "object", + "properties": { + "class": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "kv": { + "type": "integer" + }, + "power_w": { + "type": "number" + } + } + }, + "transport.RaceListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.LobbyRace" + } + }, + "next_cursor": { + "type": "string", + "example": "MTcwMDAwMDAwMDAwMDpyYWNlLWFiYzpmaW5pc2hlZA" + } + } + }, + "transport.RacePlan": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 0 + }, + "created_ms": { + "type": "integer" + }, + "enabled": { + "type": "boolean", + "example": true + }, + "fires_done": { + "type": "integer" + }, + "id": { + "type": "string", + "example": "plan-1730000-1" + }, + "interval_s": { + "type": "integer", + "example": 3600 + }, + "laps": { + "type": "integer", + "example": 10 + }, + "max_cars": { + "type": "integer", + "example": 4 + }, + "name": { + "type": "string", + "example": "Daily Monza" + }, + "next_fire_ms": { + "type": "integer" + }, + "start_at_ms": { + "type": "integer", + "example": 1730000000000 + }, + "time_limit_s": { + "type": "integer" + }, + "track_id": { + "type": "string", + "example": "default" + }, + "updated_ms": { + "type": "integer" + } + } + }, + "transport.RacePlanCreateRequest": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 0 + }, + "enabled": { + "type": "boolean", + "example": true + }, + "id": { + "type": "string", + "example": "plan-1730000-1" + }, + "interval_s": { + "type": "integer", + "example": 3600 + }, + "laps": { + "type": "integer", + "example": 10 + }, + "max_cars": { + "type": "integer", + "example": 4 + }, + "name": { + "type": "string", + "example": "Daily Monza" + }, + "start_at_ms": { + "type": "integer", + "example": 1730000000000 + }, + "time_limit_s": { + "type": "integer" + }, + "track_id": { + "type": "string", + "example": "default" + } + } + }, + "transport.RacePlanListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.RacePlan" + } + }, + "next_cursor": { + "type": "string" + } + } + }, + "transport.RacePodiumEntry": { + "type": "object", + "properties": { + "driver_id": { + "type": "string", + "example": "driver-alice" + }, + "name": { + "type": "string", + "example": "driver-alice" + }, + "position": { + "type": "integer", + "example": 1 + }, + "total_time_ms": { + "type": "integer", + "example": 123456 + } + } + }, + "transport.RaceQueueEntry": { + "type": "object", + "properties": { + "driver_id": { + "type": "string", + "example": "driver-42" + }, + "enqueued_ms": { + "type": "integer", + "example": 1730000000000 + }, + "plan_id": { + "type": "string", + "example": "plan-1730000-1" + }, + "race_id": { + "type": "string", + "example": "race-1730000-7" + } + } + }, + "transport.RaceQueueJoinRequest": { + "type": "object", + "properties": { + "driver_id": { + "type": "string", + "example": "driver-42" + }, + "limit": { + "type": "integer", + "example": 3 + } + } + }, + "transport.RaceQueueJoinResponse": { + "type": "object", + "properties": { + "joined": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.RaceQueueEntry" + } + }, + "skipped": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "race-1", + "race-2" + ] + } + } + }, + "transport.RaceQueueListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.RaceQueueEntry" + } + } + } + }, + "transport.RaceUpcomingItem": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/definitions/transport.LobbyRace" + }, + "plan_id": { + "type": "string" + }, + "queue_len": { + "type": "integer" + }, + "start_at_ms": { + "type": "integer" + } + } + }, + "transport.RaceUpcomingResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.RaceUpcomingItem" + } + } + } + }, + "transport.TrackAck": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "track": { + "$ref": "#/definitions/transport.TrackWire" + } + } + }, + "transport.TrackBounds": { + "type": "object", + "properties": { + "length_m": { + "type": "number" + }, + "max_x": { + "type": "number" + }, + "max_y": { + "type": "number" + }, + "min_x": { + "type": "number" + }, + "min_y": { + "type": "number" + }, + "width_m": { + "type": "number" + } + } + }, + "transport.TrackCreateRequest": { + "type": "object", + "properties": { + "track": { + "$ref": "#/definitions/transport.TrackWire" + } + } + }, + "transport.TrackPatch": { + "type": "object", + "properties": { + "centerline": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.TrackWaypoint" + } + }, + "description": { + "type": "string" + }, + "lane_width_m": { + "type": "number" + }, + "length_m": { + "type": "number" + }, + "name": { + "type": "string" + }, + "scale": { + "type": "integer" + }, + "surface": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "visibility": { + "type": "string" + }, + "width_m": { + "type": "number" + } + } + }, + "transport.TrackUpdateRequest": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "patch": { + "$ref": "#/definitions/transport.TrackPatch" + } + } + }, + "transport.TrackWaypoint": { + "type": "object", + "properties": { + "curvature": { + "type": "number" + }, + "heading_rad": { + "type": "number" + }, + "speed_ms": { + "type": "number" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + }, + "transport.TrackWire": { + "type": "object", + "properties": { + "author_id": { + "type": "string" + }, + "best_lap_holder": { + "type": "string" + }, + "best_lap_ms": { + "type": "integer" + }, + "bounds": { + "$ref": "#/definitions/transport.TrackBounds" + }, + "centerline": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.TrackWaypoint" + } + }, + "created_ms": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lane_width_m": { + "type": "number" + }, + "length_m": { + "type": "number" + }, + "name": { + "type": "string" + }, + "scale": { + "type": "integer" + }, + "surface": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "updated_ms": { + "type": "integer" + }, + "visibility": { + "type": "string" + }, + "width_m": { + "type": "number" + } + } + } + } +} \ No newline at end of file diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml new file mode 100644 index 0000000..f7e2764 --- /dev/null +++ b/server/docs/swagger.yaml @@ -0,0 +1,1926 @@ +basePath: / +definitions: + transport.BatteryWire: + properties: + capacity_mah: + type: integer + cells: + type: integer + chemistry: + type: string + voltage_v: + type: number + type: object + transport.CarAck: + properties: + car: + $ref: '#/definitions/transport.CarWire' + error: + type: string + id: + type: string + ok: + type: boolean + type: object + transport.CarCreateRequest: + properties: + car: + $ref: '#/definitions/transport.CarWire' + type: object + transport.CarPatch: + properties: + active: + type: boolean + avatar_url: + type: string + battery: + $ref: '#/definitions/transport.BatteryWire' + chassis: + $ref: '#/definitions/transport.ChassisWire' + color_hex: + type: string + drive: + type: string + height_mm: + type: number + length_mm: + type: number + motor: + $ref: '#/definitions/transport.MotorWire' + name: + type: string + scale: + type: integer + top_speed_ms: + type: number + visibility: + type: string + weight_g: + type: number + width_mm: + type: number + type: object + transport.CarUpdateRequest: + properties: + id: + type: string + patch: + $ref: '#/definitions/transport.CarPatch' + type: object + transport.CarWire: + properties: + active: + type: boolean + avatar_url: + type: string + battery: + $ref: '#/definitions/transport.BatteryWire' + best_lap_ms: + type: integer + chassis: + $ref: '#/definitions/transport.ChassisWire' + color_hex: + type: string + created_ms: + type: integer + drive: + type: string + height_mm: + type: number + id: + type: string + length_mm: + type: number + motor: + $ref: '#/definitions/transport.MotorWire' + name: + type: string + owner_id: + type: string + scale: + type: integer + top_speed_ms: + type: number + total_distance_m: + type: number + total_laps: + type: integer + total_races: + type: integer + updated_ms: + type: integer + visibility: + type: string + weight_g: + type: number + width_mm: + type: number + type: object + transport.ChassisWire: + properties: + material: + type: string + model: + type: string + printed: + type: boolean + track_mm: + type: number + wheelbase_mm: + type: number + type: object + transport.Clan: + properties: + avatar_url: + example: https://cdn.example.com/c/ace.png + type: string + created_ms: + type: integer + id: + example: clan-1782000-1 + type: string + name: + example: Ace Racing + type: string + tag: + example: ACE + type: string + updated_ms: + type: integer + type: object + transport.ClanCreateRequest: + properties: + avatar_url: + example: https://cdn.example.com/c/ace.png + type: string + id: + example: clan-1782000-1 + type: string + name: + example: Ace Racing + type: string + tag: + example: ACE + type: string + type: object + transport.ClanListResponse: + properties: + count: + type: integer + items: + items: + $ref: '#/definitions/transport.Clan' + type: array + type: object + transport.ClanUpdateRequest: + properties: + avatar_url: + example: https://cdn.example.com/c/ace.png + type: string + name: + example: Ace Racing + type: string + type: object + transport.Driver: + properties: + avatar_url: + example: https://cdn.example.com/u/ace.png + type: string + clan_id: + example: clan-1782000-1 + type: string + created_ms: + type: integer + id: + example: driver-1782000-123 + type: string + name: + example: Alice + type: string + nickname: + example: ACE + type: string + updated_ms: + type: integer + type: object + transport.DriverCreateRequest: + properties: + avatar_url: + example: https://cdn.example.com/u/ace.png + type: string + clan_id: + example: clan-1782000-1 + type: string + id: + example: driver-1782000-123 + type: string + name: + example: Alice + type: string + nickname: + example: ACE + type: string + type: object + transport.DriverListResponse: + properties: + count: + type: integer + items: + items: + $ref: '#/definitions/transport.Driver' + type: array + type: object + transport.DriverUpdateRequest: + properties: + avatar_url: + example: https://cdn.example.com/u/ace.png + type: string + clan_id: + example: clan-1782000-1 + type: string + name: + example: Alice + type: string + type: object + transport.Envelope: + properties: + payload: {} + seq: + type: integer + ts_ms: + type: integer + type: + $ref: '#/definitions/transport.MessageType' + type: object + transport.LobbyRace: + properties: + created_ms: + type: integer + driver_ids: + items: + type: string + type: array + id: + type: string + laps: + type: integer + max_cars: + type: integer + name: + type: string + podium: + description: Podium is populated only for finished races. Empty for live ones. + items: + $ref: '#/definitions/transport.RacePodiumEntry' + type: array + started_ms: + type: integer + status: + type: string + time_limit_s: + type: integer + track_id: + type: string + type: object + transport.MessageType: + enum: + - client_hello + - join_race + - leave_race + - input_state + - chat_message + - ping + - lobby_list + - lobby_create + - lobby_quick_play + - lobby_join_race + - lobby_leave_race + - lobby_kick_driver + - lobby_delete + - track_list + - track_get + - track_create + - track_update + - track_delete + - car_list + - car_get + - car_create + - car_update + - car_delete + - stats_request + - server_hello + - race_snapshot + - input_ack + - correction + - race_event + - pong + - error + - lobby_snapshot + - track_snapshot + - track_ack + - car_snapshot + - car_ack + - catalog_summary + - stats_snapshot + - driver_profile + - lap_recorded + - race_result + - race_standings + type: string + x-enum-varnames: + - TypeClientHello + - TypeJoinRace + - TypeLeaveRace + - TypeInputState + - TypeChatMessage + - TypePing + - TypeLobbyList + - TypeLobbyCreate + - TypeLobbyQuickPlay + - TypeLobbyJoinRace + - TypeLobbyLeaveRace + - TypeLobbyKickDriver + - TypeLobbyDelete + - TypeTrackList + - TypeTrackGet + - TypeTrackCreate + - TypeTrackUpdate + - TypeTrackDelete + - TypeCarList + - TypeCarGet + - TypeCarCreate + - TypeCarUpdate + - TypeCarDelete + - TypeStatsRequest + - TypeServerHello + - TypeRaceSnapshot + - TypeInputAck + - TypeCorrection + - TypeRaceEvent + - TypePong + - TypeError + - TypeLobbySnapshot + - TypeTrackSnapshot + - TypeTrackAck + - TypeCarSnapshot + - TypeCarAck + - TypeCatalogSummary + - TypeStatsSnapshot + - TypeDriverProfile + - TypeLapRecorded + - TypeRaceResult + - TypeRaceStandings + transport.MotorWire: + properties: + class: + type: string + kind: + type: string + kv: + type: integer + power_w: + type: number + type: object + transport.RaceListResponse: + properties: + count: + type: integer + items: + items: + $ref: '#/definitions/transport.LobbyRace' + type: array + next_cursor: + example: MTcwMDAwMDAwMDAwMDpyYWNlLWFiYzpmaW5pc2hlZA + type: string + type: object + transport.RacePlan: + properties: + count: + example: 0 + type: integer + created_ms: + type: integer + enabled: + example: true + type: boolean + fires_done: + type: integer + id: + example: plan-1730000-1 + type: string + interval_s: + example: 3600 + type: integer + laps: + example: 10 + type: integer + max_cars: + example: 4 + type: integer + name: + example: Daily Monza + type: string + next_fire_ms: + type: integer + start_at_ms: + example: 1730000000000 + type: integer + time_limit_s: + type: integer + track_id: + example: default + type: string + updated_ms: + type: integer + type: object + transport.RacePlanCreateRequest: + properties: + count: + example: 0 + type: integer + enabled: + example: true + type: boolean + id: + example: plan-1730000-1 + type: string + interval_s: + example: 3600 + type: integer + laps: + example: 10 + type: integer + max_cars: + example: 4 + type: integer + name: + example: Daily Monza + type: string + start_at_ms: + example: 1730000000000 + type: integer + time_limit_s: + type: integer + track_id: + example: default + type: string + type: object + transport.RacePlanListResponse: + properties: + count: + type: integer + items: + items: + $ref: '#/definitions/transport.RacePlan' + type: array + next_cursor: + type: string + type: object + transport.RacePodiumEntry: + properties: + driver_id: + example: driver-alice + type: string + name: + example: driver-alice + type: string + position: + example: 1 + type: integer + total_time_ms: + example: 123456 + type: integer + type: object + transport.RaceQueueEntry: + properties: + driver_id: + example: driver-42 + type: string + enqueued_ms: + example: 1730000000000 + type: integer + plan_id: + example: plan-1730000-1 + type: string + race_id: + example: race-1730000-7 + type: string + type: object + transport.RaceQueueJoinRequest: + properties: + driver_id: + example: driver-42 + type: string + limit: + example: 3 + type: integer + type: object + transport.RaceQueueJoinResponse: + properties: + joined: + items: + $ref: '#/definitions/transport.RaceQueueEntry' + type: array + skipped: + example: + - race-1 + - race-2 + items: + type: string + type: array + type: object + transport.RaceQueueListResponse: + properties: + count: + type: integer + items: + items: + $ref: '#/definitions/transport.RaceQueueEntry' + type: array + type: object + transport.RaceUpcomingItem: + properties: + meta: + $ref: '#/definitions/transport.LobbyRace' + plan_id: + type: string + queue_len: + type: integer + start_at_ms: + type: integer + type: object + transport.RaceUpcomingResponse: + properties: + count: + type: integer + items: + items: + $ref: '#/definitions/transport.RaceUpcomingItem' + type: array + type: object + transport.TrackAck: + properties: + error: + type: string + id: + type: string + ok: + type: boolean + track: + $ref: '#/definitions/transport.TrackWire' + type: object + transport.TrackBounds: + properties: + length_m: + type: number + max_x: + type: number + max_y: + type: number + min_x: + type: number + min_y: + type: number + width_m: + type: number + type: object + transport.TrackCreateRequest: + properties: + track: + $ref: '#/definitions/transport.TrackWire' + type: object + transport.TrackPatch: + properties: + centerline: + items: + $ref: '#/definitions/transport.TrackWaypoint' + type: array + description: + type: string + lane_width_m: + type: number + length_m: + type: number + name: + type: string + scale: + type: integer + surface: + type: string + tags: + items: + type: string + type: array + visibility: + type: string + width_m: + type: number + type: object + transport.TrackUpdateRequest: + properties: + id: + type: string + patch: + $ref: '#/definitions/transport.TrackPatch' + type: object + transport.TrackWaypoint: + properties: + curvature: + type: number + heading_rad: + type: number + speed_ms: + type: number + x: + type: number + "y": + type: number + type: object + transport.TrackWire: + properties: + author_id: + type: string + best_lap_holder: + type: string + best_lap_ms: + type: integer + bounds: + $ref: '#/definitions/transport.TrackBounds' + centerline: + items: + $ref: '#/definitions/transport.TrackWaypoint' + type: array + created_ms: + type: integer + description: + type: string + id: + type: string + lane_width_m: + type: number + length_m: + type: number + name: + type: string + scale: + type: integer + surface: + type: string + tags: + items: + type: string + type: array + updated_ms: + type: integer + visibility: + type: string + width_m: + type: number + type: object +host: localhost:8080 +info: + contact: + name: x0gp + description: |- + Minimal HTTP+WebSocket server for the x0gp RC racing PoC. REST endpoints under /api/* manage the track/car catalog; /health and /stats expose server liveness and realtime metrics; /ws carries the realtime race protocol. + Auth is intentionally disabled in PoC mode (X0GP_DEV_MODE=true). All catalog mutations are allowed without a JWT. + license: + name: MIT + title: x0gp PoC server API + version: 0.1.0-poc +paths: + /api/cars: + get: + description: 'Returns all cars visible to the caller. Optional query filters: + `scope=system|public|private`, `owner_id=`.' + parameters: + - description: Filter by visibility + enum: + - system + - public + - private + in: query + name: scope + type: string + - description: Filter by owner id + in: query + name: owner_id + type: string + produces: + - application/json + responses: + "200": + description: List of cars with count + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: List cars + tags: + - cars + post: + consumes: + - application/json + description: Creates a new car. The server fills `id` if the client sends an + empty one. `owner_id` should be the caller's id in production (auth is disabled + in PoC). + parameters: + - description: Car to create + in: body + name: car + required: true + schema: + $ref: '#/definitions/transport.CarCreateRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/transport.CarAck' + "400": + description: Invalid input + schema: + additionalProperties: true + type: object + "409": + description: Car id already exists + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Create a car + tags: + - cars + /api/cars/{id}: + delete: + description: Dispatches on HTTP method. The id is taken from the URL path (`/api/cars/{id}`). + System cars are immutable and cannot be updated or deleted. + parameters: + - description: Car id + in: path + name: id + required: true + type: string + - description: Patch payload (only for PUT) + in: body + name: car + schema: + $ref: '#/definitions/transport.CarUpdateRequest' + produces: + - application/json + responses: + "200": + description: Car payload (GET) or update ack (PUT) + schema: + $ref: '#/definitions/transport.CarWire' + "204": + description: Delete ack + schema: + $ref: '#/definitions/transport.CarAck' + "400": + description: Bad request / invalid input + schema: + additionalProperties: true + type: object + "403": + description: System car is immutable + schema: + additionalProperties: true + type: object + "404": + description: Car not found + schema: + additionalProperties: true + type: object + "409": + description: Car id already exists + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Get / update / delete a car by id + tags: + - cars + get: + description: Dispatches on HTTP method. The id is taken from the URL path (`/api/cars/{id}`). + System cars are immutable and cannot be updated or deleted. + parameters: + - description: Car id + in: path + name: id + required: true + type: string + - description: Patch payload (only for PUT) + in: body + name: car + schema: + $ref: '#/definitions/transport.CarUpdateRequest' + produces: + - application/json + responses: + "200": + description: Car payload (GET) or update ack (PUT) + schema: + $ref: '#/definitions/transport.CarWire' + "204": + description: Delete ack + schema: + $ref: '#/definitions/transport.CarAck' + "400": + description: Bad request / invalid input + schema: + additionalProperties: true + type: object + "403": + description: System car is immutable + schema: + additionalProperties: true + type: object + "404": + description: Car not found + schema: + additionalProperties: true + type: object + "409": + description: Car id already exists + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Get / update / delete a car by id + tags: + - cars + put: + description: Dispatches on HTTP method. The id is taken from the URL path (`/api/cars/{id}`). + System cars are immutable and cannot be updated or deleted. + parameters: + - description: Car id + in: path + name: id + required: true + type: string + - description: Patch payload (only for PUT) + in: body + name: car + schema: + $ref: '#/definitions/transport.CarUpdateRequest' + produces: + - application/json + responses: + "200": + description: Car payload (GET) or update ack (PUT) + schema: + $ref: '#/definitions/transport.CarWire' + "204": + description: Delete ack + schema: + $ref: '#/definitions/transport.CarAck' + "400": + description: Bad request / invalid input + schema: + additionalProperties: true + type: object + "403": + description: System car is immutable + schema: + additionalProperties: true + type: object + "404": + description: Car not found + schema: + additionalProperties: true + type: object + "409": + description: Car id already exists + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Get / update / delete a car by id + tags: + - cars + /api/catalog: + get: + description: 'Returns the full in-memory catalog: all tracks, all cars, and + aggregate counters. This is the cheapest way for a UI to bootstrap its initial + state.' + produces: + - application/json + responses: + "200": + description: Snapshot of tracks, cars and summary counters + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Combined catalog snapshot + tags: + - catalog + /api/clans: + get: + description: Returns clans ordered by tag. Limit 1..200, default 50. + parameters: + - description: Page size + in: query + name: limit + type: integer + - description: Page offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: Page of clans + schema: + $ref: '#/definitions/transport.ClanListResponse' + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: List clans + tags: + - clans + post: + consumes: + - application/json + description: Creates a new clan. `tag` must be exactly 3 uppercase ASCII letters + (e.g. "ACE") and unique. + parameters: + - description: Clan to create + in: body + name: clan + required: true + schema: + $ref: '#/definitions/transport.ClanCreateRequest' + produces: + - application/json + responses: + "201": + description: Created clan + schema: + $ref: '#/definitions/transport.Clan' + "400": + description: Invalid input (tag must be 3 uppercase letters) + schema: + additionalProperties: true + type: object + "409": + description: Clan with that tag already exists + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Create a clan + tags: + - clans + /api/clans/{id}: + delete: + description: Dispatches on HTTP method. + parameters: + - description: Clan id + in: path + name: id + required: true + type: string + - description: Patch payload (only for PUT) + in: body + name: clan + schema: + $ref: '#/definitions/transport.ClanUpdateRequest' + produces: + - application/json + responses: + "200": + description: Clan payload or update result + schema: + $ref: '#/definitions/transport.Clan' + "204": + description: Delete ack + schema: + $ref: '#/definitions/transport.Clan' + "400": + description: Bad request + schema: + additionalProperties: true + type: object + "404": + description: Clan not found + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Get / update / delete a clan by id + tags: + - clans + get: + description: Dispatches on HTTP method. + parameters: + - description: Clan id + in: path + name: id + required: true + type: string + - description: Patch payload (only for PUT) + in: body + name: clan + schema: + $ref: '#/definitions/transport.ClanUpdateRequest' + produces: + - application/json + responses: + "200": + description: Clan payload or update result + schema: + $ref: '#/definitions/transport.Clan' + "204": + description: Delete ack + schema: + $ref: '#/definitions/transport.Clan' + "400": + description: Bad request + schema: + additionalProperties: true + type: object + "404": + description: Clan not found + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Get / update / delete a clan by id + tags: + - clans + put: + description: Dispatches on HTTP method. + parameters: + - description: Clan id + in: path + name: id + required: true + type: string + - description: Patch payload (only for PUT) + in: body + name: clan + schema: + $ref: '#/definitions/transport.ClanUpdateRequest' + produces: + - application/json + responses: + "200": + description: Clan payload or update result + schema: + $ref: '#/definitions/transport.Clan' + "204": + description: Delete ack + schema: + $ref: '#/definitions/transport.Clan' + "400": + description: Bad request + schema: + additionalProperties: true + type: object + "404": + description: Clan not found + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Get / update / delete a clan by id + tags: + - clans + /api/drivers: + get: + description: Returns drivers ordered by nickname. Optional `clan_id` filter. + parameters: + - description: Page size + in: query + name: limit + type: integer + - description: Page offset + in: query + name: offset + type: integer + - description: Filter by clan id + in: query + name: clan_id + type: string + produces: + - application/json + responses: + "200": + description: Page of drivers + schema: + $ref: '#/definitions/transport.DriverListResponse' + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: List drivers + tags: + - drivers + post: + consumes: + - application/json + description: Creates a new driver. `nickname` must be exactly 3 uppercase ASCII + letters (e.g. "ACE") and unique. `clan_id` is optional and must reference + an existing clan. + parameters: + - description: Driver to create + in: body + name: driver + required: true + schema: + $ref: '#/definitions/transport.DriverCreateRequest' + produces: + - application/json + responses: + "201": + description: Created driver + schema: + $ref: '#/definitions/transport.Driver' + "400": + description: Invalid input (nickname must be 3 uppercase letters) + schema: + additionalProperties: true + type: object + "409": + description: Driver with that nickname already exists + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Create a driver + tags: + - drivers + /api/drivers/{id}: + delete: + description: Dispatches on HTTP method. Path `/api/drivers/by-nick/{nick}` is + a convenience that returns the driver by 3-letter nickname. + parameters: + - description: Driver id + in: path + name: id + required: true + type: string + - description: Patch payload (only for PUT) + in: body + name: driver + schema: + $ref: '#/definitions/transport.DriverUpdateRequest' + produces: + - application/json + responses: + "200": + description: Driver payload or update result + schema: + $ref: '#/definitions/transport.Driver' + "204": + description: Delete ack + schema: + $ref: '#/definitions/transport.Driver' + "400": + description: Bad request + schema: + additionalProperties: true + type: object + "404": + description: Driver not found + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Get / update / delete a driver by id + tags: + - drivers + get: + description: Dispatches on HTTP method. Path `/api/drivers/by-nick/{nick}` is + a convenience that returns the driver by 3-letter nickname. + parameters: + - description: Driver id + in: path + name: id + required: true + type: string + - description: Patch payload (only for PUT) + in: body + name: driver + schema: + $ref: '#/definitions/transport.DriverUpdateRequest' + produces: + - application/json + responses: + "200": + description: Driver payload or update result + schema: + $ref: '#/definitions/transport.Driver' + "204": + description: Delete ack + schema: + $ref: '#/definitions/transport.Driver' + "400": + description: Bad request + schema: + additionalProperties: true + type: object + "404": + description: Driver not found + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Get / update / delete a driver by id + tags: + - drivers + put: + description: Dispatches on HTTP method. Path `/api/drivers/by-nick/{nick}` is + a convenience that returns the driver by 3-letter nickname. + parameters: + - description: Driver id + in: path + name: id + required: true + type: string + - description: Patch payload (only for PUT) + in: body + name: driver + schema: + $ref: '#/definitions/transport.DriverUpdateRequest' + produces: + - application/json + responses: + "200": + description: Driver payload or update result + schema: + $ref: '#/definitions/transport.Driver' + "204": + description: Delete ack + schema: + $ref: '#/definitions/transport.Driver' + "400": + description: Bad request + schema: + additionalProperties: true + type: object + "404": + description: Driver not found + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Get / update / delete a driver by id + tags: + - drivers + /api/races: + get: + description: |- + Returns one keyset page of races. Live races (status lobby|racing) come from in-memory lobby.Service; finished races come from Postgres. The two sources are merged and ordered by (started_ms DESC, id ASC). The cursor is opaque: pass back the next_cursor verbatim from a previous page. + ## Filters + - `status` (comma-separated, e.g. "racing,finished"). Recognised values: all, lobby, racing, finished. + - `track_id` (exact match against a physical track id; custom lobbies are not supported in this build). + - `limit` (1..200, default 50) + parameters: + - description: Comma-separated status filter + enum: + - all + - lobby + - racing + - finished + in: query + name: status + type: string + - description: Filter by physical track id + in: query + name: track_id + type: string + - description: Opaque keyset cursor (next_cursor from previous page) + in: query + name: cursor + type: string + - description: Page size (1..200, default 50) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: Page of races + schema: + $ref: '#/definitions/transport.RaceListResponse' + "400": + description: Bad request / invalid cursor / invalid status + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: List races (keyset paginated) + tags: + - races + /api/races/plans: + get: + description: Returns race plans in ascending start_at order. Pass back the `next_cursor` + to get the next page. + parameters: + - description: Opaque keyset cursor + in: query + name: cursor + type: string + - description: Page size (default 50) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: Page of plans + schema: + $ref: '#/definitions/transport.RacePlanListResponse' + "400": + description: Invalid cursor + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: List race plans (keyset paginated) + tags: + - plans + post: + consumes: + - application/json + description: Creates a recurring or one-off scheduled race. The scheduler materialises + a lobby race when `next_fire_ms` <= now. If `interval_s` > 0 the plan repeats; + if `count` > 0 the plan disables itself after that many fires. + parameters: + - description: Plan to create + in: body + name: plan + required: true + schema: + $ref: '#/definitions/transport.RacePlanCreateRequest' + produces: + - application/json + responses: + "201": + description: Created plan + schema: + $ref: '#/definitions/transport.RacePlan' + "400": + description: Invalid input + schema: + additionalProperties: true + type: object + "409": + description: Plan id already exists + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Create a race plan + tags: + - plans + /api/races/plans/{id}: + delete: + description: Removes a plan by id. Materialised races already in the lobby are + not affected. + parameters: + - description: Plan id + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: ok + schema: + additionalProperties: true + type: object + "404": + description: Plan not found + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Delete a race plan + tags: + - plans + /api/races/queue: + delete: + description: Removes a single (driver, race) entry from the queue. Does not + detach the driver from the race if already joined. + parameters: + - description: Driver id + in: header + name: X-Driver-Id + type: string + - description: Driver id + in: query + name: driver_id + type: string + - description: Race id + in: query + name: race_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: ok + schema: + additionalProperties: true + type: object + "400": + description: driver_id and race_id required + schema: + additionalProperties: true + type: object + "404": + description: queue entry not found + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Leave a queue entry + tags: + - races + get: + description: Returns the queue entries for a driver, oldest first. + parameters: + - description: Driver id + in: header + name: X-Driver-Id + type: string + - description: Driver id (alternative to header) + in: query + name: driver_id + type: string + produces: + - application/json + responses: + "200": + description: Queue entries + schema: + $ref: '#/definitions/transport.RaceQueueListResponse' + "400": + description: driver_id required + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: List driver's queue entries + tags: + - races + /api/races/queue/join: + post: + consumes: + - application/json + description: Puts the driver in the queue for each of the next N (default 3) + lobby|countdown races, soonest first. If a race has a free slot, the driver + is also attached to it (best effort). Idempotent per (driver, race). + parameters: + - description: Driver id (alternative to body) + in: header + name: X-Driver-Id + type: string + - description: Driver id and optional limit + in: body + name: body + schema: + $ref: '#/definitions/transport.RaceQueueJoinRequest' + produces: + - application/json + responses: + "200": + description: Queue entries created + schema: + $ref: '#/definitions/transport.RaceQueueJoinResponse' + "400": + description: driver_id required + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Enqueue driver on next N upcoming races + tags: + - races + /api/races/upcoming: + get: + description: Returns up to `limit` races currently in status lobby|countdown, + ordered soonest first. Useful for the "join queue" UI and dashboard. + parameters: + - description: Number of races to return (1..16, default 3) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: Upcoming races + schema: + $ref: '#/definitions/transport.RaceUpcomingResponse' + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Next N upcoming races + tags: + - races + /api/tracks: + get: + description: 'Returns all tracks visible to the caller. Optional query filters: + `scope=system|public|private`, `tag=`.' + parameters: + - description: Filter by visibility + enum: + - system + - public + - private + in: query + name: scope + type: string + - description: Filter by tag (case-insensitive) + in: query + name: tag + type: string + produces: + - application/json + responses: + "200": + description: List of tracks with count + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: List tracks + tags: + - tracks + post: + consumes: + - application/json + description: Creates a new track. The server fills `id` if the client sends + an empty one. `author_id` should be the caller's id in production (auth is + disabled in PoC). + parameters: + - description: Track to create + in: body + name: track + required: true + schema: + $ref: '#/definitions/transport.TrackCreateRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/transport.TrackAck' + "400": + description: Invalid input + schema: + additionalProperties: true + type: object + "409": + description: Track id already exists + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Create a track + tags: + - tracks + /api/tracks/{id}: + delete: + description: Dispatches on HTTP method. The id is taken from the URL path (`/api/tracks/{id}`). + System tracks are immutable and cannot be updated or deleted. + parameters: + - description: Track id + in: path + name: id + required: true + type: string + - description: Patch payload (only for PUT) + in: body + name: track + schema: + $ref: '#/definitions/transport.TrackUpdateRequest' + produces: + - application/json + responses: + "200": + description: Track payload (GET) or update ack (PUT) + schema: + $ref: '#/definitions/transport.TrackWire' + "204": + description: Delete ack + schema: + $ref: '#/definitions/transport.TrackAck' + "400": + description: Bad request / invalid input + schema: + additionalProperties: true + type: object + "403": + description: System track is immutable + schema: + additionalProperties: true + type: object + "404": + description: Track not found + schema: + additionalProperties: true + type: object + "409": + description: Track id already exists + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Get / update / delete a track by id + tags: + - tracks + get: + description: Dispatches on HTTP method. The id is taken from the URL path (`/api/tracks/{id}`). + System tracks are immutable and cannot be updated or deleted. + parameters: + - description: Track id + in: path + name: id + required: true + type: string + - description: Patch payload (only for PUT) + in: body + name: track + schema: + $ref: '#/definitions/transport.TrackUpdateRequest' + produces: + - application/json + responses: + "200": + description: Track payload (GET) or update ack (PUT) + schema: + $ref: '#/definitions/transport.TrackWire' + "204": + description: Delete ack + schema: + $ref: '#/definitions/transport.TrackAck' + "400": + description: Bad request / invalid input + schema: + additionalProperties: true + type: object + "403": + description: System track is immutable + schema: + additionalProperties: true + type: object + "404": + description: Track not found + schema: + additionalProperties: true + type: object + "409": + description: Track id already exists + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Get / update / delete a track by id + tags: + - tracks + put: + description: Dispatches on HTTP method. The id is taken from the URL path (`/api/tracks/{id}`). + System tracks are immutable and cannot be updated or deleted. + parameters: + - description: Track id + in: path + name: id + required: true + type: string + - description: Patch payload (only for PUT) + in: body + name: track + schema: + $ref: '#/definitions/transport.TrackUpdateRequest' + produces: + - application/json + responses: + "200": + description: Track payload (GET) or update ack (PUT) + schema: + $ref: '#/definitions/transport.TrackWire' + "204": + description: Delete ack + schema: + $ref: '#/definitions/transport.TrackAck' + "400": + description: Bad request / invalid input + schema: + additionalProperties: true + type: object + "403": + description: System track is immutable + schema: + additionalProperties: true + type: object + "404": + description: Track not found + schema: + additionalProperties: true + type: object + "409": + description: Track id already exists + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Get / update / delete a track by id + tags: + - tracks + /health: + get: + description: Returns 200 OK with current engine phase, WS connection count and + total snapshot drops. Intended for load-balancer health checks. + produces: + - application/json + responses: + "200": + description: Server is up + schema: + additionalProperties: true + type: object + summary: Liveness probe + tags: + - system + /stats: + get: + description: 'Returns the current race phase plus realtime counters: active + WS connections and total snapshots dropped due to slow consumers.' + produces: + - application/json + responses: + "200": + description: Runtime counters + schema: + additionalProperties: true + type: object + summary: Realtime runtime stats + tags: + - system + /ws: + get: + description: |- + Opens a bidirectional WebSocket for the realtime race protocol. The server replies with `server_hello`, then broadcasts `race_snapshot` frames at `X0GP_SNAPSHOT_HZ`. + ## Frame format + Every frame is a JSON envelope of the form: + ```json + { "type": "", "seq": 42, "ts_ms": 1700000000000, "payload": { ... } } + ``` + `type` discriminates the payload; `payload` is documented per message type in the schemas listed in `components/schemas` (see `Envelope`, `ClientHello`, `ServerHello`, `InputState`, `JoinRace`, `LeaveRace`, `RaceSnapshot`, `InputAck`, `RaceEvent`, `Ping`, `Pong`, `ErrorMsg`, plus the catalog/lobby/stats envelopes). + ## Client -> Server + - `client_hello` — first frame after connect. Sent once. + - `join_race` / `leave_race` — join or leave the race session. + - `input_state` — high-rate control (steering/throttle/brake/gear). Send at 60-120 Hz. + - `ping` — round-trip time probe. + - `track_*` / `car_*` — catalog CRUD (mirror of the REST /api/* endpoints). + - `lobby_*` — lobby management. + - `stats_request` — ask for a stats snapshot. + ## Server -> Client + - `server_hello` — handshake reply; includes session id and engine config. + - `race_snapshot` — periodic authoritative state broadcast. + - `input_ack` — per-input acknowledgement (optional; drop-tolerant). + - `race_event` — start/lap/sector/dnf/finish. + - `track_snapshot` / `car_snapshot` / `catalog_summary` — catalog feeds on change. + - `lobby_snapshot` — full lobby state on change. + - `stats_snapshot` / `race_standings` / `lap_recorded` / `race_result` — stats feeds. + - `error` — protocol or validation error. + ## Notes + - Origin checks are disabled in PoC mode (`CheckOrigin: always-allow`). + - Ping interval: 20 s. Pong wait: 60 s. Max frame size: 8 KiB. + - Input acks are best-effort and silently dropped under load. + responses: + "101": + description: Switching protocols. Subsequent frames follow the envelope + schema. + schema: + $ref: '#/definitions/transport.Envelope' + summary: WebSocket endpoint (binary JSON envelope protocol) + tags: + - websocket +schemes: +- http +- ws +swagger: "2.0" diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..d6cb92c --- /dev/null +++ b/server/go.mod @@ -0,0 +1,31 @@ +module github.com/x0gp/server + +go 1.26 + +require ( + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + github.com/jackc/pgx/v5 v5.10.0 + github.com/joho/godotenv v1.5.1 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + 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/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 + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/swaggo/files v1.0.1 // indirect + github.com/swaggo/http-swagger v1.3.4 // indirect + github.com/swaggo/swag v1.16.4 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect + golang.org/x/tools v0.36.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..8ab6ef6 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,100 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= +github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= +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/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= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= +github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ= +github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= +github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/internal/catalog/catalog.go b/server/internal/catalog/catalog.go new file mode 100644 index 0000000..a744635 --- /dev/null +++ b/server/internal/catalog/catalog.go @@ -0,0 +1,609 @@ +package catalog + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + "time" +) + +// Errors returned by the catalog service. +var ( + ErrNotFound = errors.New("not found") + ErrAlreadyExists = errors.New("already exists") + ErrInvalidInput = errors.New("invalid input") + ErrImmutableSystem = errors.New("system resources are immutable") + ErrStoreMissing = errors.New("catalog: store not configured") +) + +// Validation bounds for the user's setup: +// - largest room 4.46 m × 3.19 m → tracks ≤ 4.5 m × 3.2 m bounding box +// - car scale 1/27 → car length 50..200 mm (typical 1/27 touring envelope) +// - 3D-printer Bambu A1 build volume → 220×220×250 mm +const ( + maxTrackLengthM = 4.5 + maxTrackWidthM = 3.2 + maxLaneWidthM = 1.0 + minCarLengthMm = 40.0 + maxCarLengthMm = 260.0 // 1/24 F1 ≈ 238 мм + minCarWidthMm = 20.0 + maxCarWidthMm = 120.0 +) + +// CreateTrackOptions are the inputs for CreateTrack. +type CreateTrackOptions struct { + ID string // optional; auto-generated if empty + Name string // required + Description string // optional + AuthorID string // optional; defaults to "system" + Visibility Visibility // optional; defaults to VisibilityPublic + Scale int // optional; defaults to 27 + LengthM float64 // optional; computed from centerline if 0 + WidthM float64 // optional; computed from centerline if 0 + LaneWidthM float64 // optional; defaults to 0.20 + Surface Surface // optional; defaults to carpet + Tags []string // optional + Centerline []Waypoint // optional; defaults to a parametric oval +} + +// Validate returns an error if opts is incomplete. +func (o CreateTrackOptions) Validate() error { + if strings.TrimSpace(o.Name) == "" { + return fmt.Errorf("%w: name required", ErrInvalidInput) + } + if o.LengthM < 0 || o.LengthM > maxTrackLengthM { + return fmt.Errorf("%w: length_m must be in [0, %.1f]", ErrInvalidInput, maxTrackLengthM) + } + if o.WidthM < 0 || o.WidthM > maxTrackWidthM { + return fmt.Errorf("%w: width_m must be in [0, %.1f]", ErrInvalidInput, maxTrackWidthM) + } + if o.LaneWidthM < 0 || o.LaneWidthM > maxLaneWidthM { + return fmt.Errorf("%w: lane_width_m must be in [0, %.1f]", ErrInvalidInput, maxLaneWidthM) + } + if o.Visibility == VisibilitySystem { + return fmt.Errorf("%w: cannot create system track via API", ErrInvalidInput) + } + return nil +} + +// CreateTrack inserts a new user-authored track. +func (s *Service) CreateTrack(ctx context.Context, opts CreateTrackOptions) (TrackMeta, error) { + if err := opts.Validate(); err != nil { + return TrackMeta{}, err + } + id := opts.ID + if id == "" { + id = slugify(opts.Name) + } + vis := opts.Visibility + if vis == "" { + vis = VisibilityPublic + } + auth := opts.AuthorID + if auth == "" { + auth = AuthorSystem + } + scale := opts.Scale + if scale == 0 { + scale = 27 + } + sfc := opts.Surface + if sfc == "" { + sfc = SurfaceCarpet + } + + if _, exists := s.getTrackLocked(id); exists { + return TrackMeta{}, fmt.Errorf("%w: track %q", ErrAlreadyExists, id) + } + + now := time.Now().UnixMilli() + t := TrackMeta{ + ID: id, + Name: strings.TrimSpace(opts.Name), + Description: opts.Description, + AuthorID: auth, + Visibility: vis, + Scale: scale, + LengthM: opts.LengthM, + WidthM: opts.WidthM, + LaneWidthM: opts.LaneWidthM, + Surface: sfc, + Tags: append([]string(nil), opts.Tags...), + Centerline: append([]Waypoint(nil), opts.Centerline...), + CreatedMs: now, + UpdatedMs: now, + } + s.normalizeTrack(&t) + + if t.Bounds.MaxX > maxTrackLengthM || t.Bounds.MaxY > maxTrackWidthM { + return TrackMeta{}, fmt.Errorf("%w: track too large for 446x319 cm room (got %.2fx%.2f m)", + ErrInvalidInput, t.Bounds.MaxX, t.Bounds.MaxY) + } + + if err := s.persistUpsertTrack(ctx, t); err != nil { + return TrackMeta{}, err + } + return t, nil +} + +// UpdateTrackOptions carries the patch fields for UpdateTrack. +type UpdateTrackOptions struct { + Name *string + Description *string + Visibility *Visibility + LengthM *float64 + WidthM *float64 + LaneWidthM *float64 + Surface *Surface + Tags *[]string + Centerline *[]Waypoint +} + +// UpdateTrack applies a partial update to an existing track. +func (s *Service) UpdateTrack(ctx context.Context, id string, opts UpdateTrackOptions) (TrackMeta, error) { + s.mu.Lock() + t, ok := s.tracks[id] + if !ok { + s.mu.Unlock() + return TrackMeta{}, fmt.Errorf("%w: track %q", ErrNotFound, id) + } + if t.Visibility == VisibilitySystem { + s.mu.Unlock() + return TrackMeta{}, fmt.Errorf("%w: track %q", ErrImmutableSystem, id) + } + + if opts.Name != nil { + t.Name = strings.TrimSpace(*opts.Name) + } + if opts.Description != nil { + t.Description = *opts.Description + } + if opts.Visibility != nil { + if *opts.Visibility == VisibilitySystem { + s.mu.Unlock() + return TrackMeta{}, fmt.Errorf("%w: cannot promote to system", ErrInvalidInput) + } + t.Visibility = *opts.Visibility + } + if opts.LengthM != nil { + t.LengthM = *opts.LengthM + } + if opts.WidthM != nil { + t.WidthM = *opts.WidthM + } + if opts.LaneWidthM != nil { + t.LaneWidthM = *opts.LaneWidthM + } + if opts.Surface != nil { + t.Surface = *opts.Surface + } + if opts.Tags != nil { + t.Tags = append([]string(nil), *opts.Tags...) + } + if opts.Centerline != nil { + t.Centerline = append([]Waypoint(nil), *opts.Centerline...) + t.Bounds = computeBounds(t.Centerline) + t.LengthM = t.Bounds.LengthM + t.WidthM = t.Bounds.WidthM + } else { + t.Bounds = computeBounds(t.Centerline) + } + t.UpdatedMs = time.Now().UnixMilli() + + if t.Bounds.MaxX > maxTrackLengthM || t.Bounds.MaxY > maxTrackWidthM { + s.mu.Unlock() + return TrackMeta{}, fmt.Errorf("%w: track too large (got %.2fx%.2f m)", + ErrInvalidInput, t.Bounds.MaxX, t.Bounds.MaxY) + } + + updated := *t + s.mu.Unlock() + + if err := s.persistUpsertTrack(ctx, updated); err != nil { + return TrackMeta{}, err + } + return updated, nil +} + +// DeleteTrack removes a track by ID. System tracks are immutable. +func (s *Service) DeleteTrack(ctx context.Context, id string) error { + s.mu.RLock() + t, ok := s.tracks[id] + s.mu.RUnlock() + if !ok { + return fmt.Errorf("%w: track %q", ErrNotFound, id) + } + if t.Visibility == VisibilitySystem { + return fmt.Errorf("%w: track %q", ErrImmutableSystem, id) + } + return s.persistDeleteTrack(ctx, id) +} + +// ------------------------------------------------------------------------- +// Cars +// ------------------------------------------------------------------------- + +// CreateCarOptions are the inputs for CreateCar. +type CreateCarOptions struct { + ID string + Name string + OwnerID string + Visibility Visibility + Scale int + + LengthMm float64 + WidthMm float64 + HeightMm float64 + WeightG float64 + + Chassis ChassisSpec + Motor MotorSpec + Battery BatterySpec + Drive Drivetrain + + TopSpeedMs float64 + ColorHex string + AvatarURL string + Active *bool // optional; defaults to true +} + +// Validate sanity-checks dimensions and required fields. +func (o CreateCarOptions) Validate() error { + if strings.TrimSpace(o.Name) == "" { + return fmt.Errorf("%w: name required", ErrInvalidInput) + } + if o.LengthMm < minCarLengthMm || o.LengthMm > maxCarLengthMm { + return fmt.Errorf("%w: length_mm must be in [%.0f, %.0f]", ErrInvalidInput, minCarLengthMm, maxCarLengthMm) + } + if o.WidthMm < minCarWidthMm || o.WidthMm > maxCarWidthMm { + return fmt.Errorf("%w: width_mm must be in [%.0f, %.0f]", ErrInvalidInput, minCarWidthMm, maxCarWidthMm) + } + if o.Visibility == VisibilitySystem { + return fmt.Errorf("%w: cannot create system car via API", ErrInvalidInput) + } + return nil +} + +// CreateCar inserts a new car. +func (s *Service) CreateCar(ctx context.Context, opts CreateCarOptions) (CarMeta, error) { + if err := opts.Validate(); err != nil { + return CarMeta{}, err + } + id := opts.ID + if id == "" { + id = slugify(opts.Name) + } + vis := opts.Visibility + if vis == "" { + vis = VisibilityPublic + } + owner := opts.OwnerID + if owner == "" { + owner = AuthorSystem + } + scale := opts.Scale + if scale == 0 { + scale = 27 + } + drive := opts.Drive + if drive == "" { + drive = Drivetrain2WD + } + active := true + if opts.Active != nil { + active = *opts.Active + } + + if _, exists := s.getCarLocked(id); exists { + return CarMeta{}, fmt.Errorf("%w: car %q", ErrAlreadyExists, id) + } + + now := time.Now().UnixMilli() + c := CarMeta{ + ID: id, + Name: strings.TrimSpace(opts.Name), + OwnerID: owner, + Visibility: vis, + Scale: scale, + LengthMm: opts.LengthMm, + WidthMm: opts.WidthMm, + HeightMm: opts.HeightMm, + WeightG: opts.WeightG, + Chassis: opts.Chassis, + Motor: opts.Motor, + Battery: opts.Battery, + Drive: drive, + TopSpeedMs: opts.TopSpeedMs, + ColorHex: opts.ColorHex, + AvatarURL: opts.AvatarURL, + Active: active, + CreatedMs: now, + UpdatedMs: now, + } + s.normalizeCar(&c) + + if err := s.persistUpsertCar(ctx, c); err != nil { + return CarMeta{}, err + } + return c, nil +} + +// UpdateCarOptions is the patch for UpdateCar. +type UpdateCarOptions struct { + Name *string + Visibility *Visibility + LengthMm *float64 + WidthMm *float64 + HeightMm *float64 + WeightG *float64 + TopSpeedMs *float64 + ColorHex *string + Active *bool + Chassis *ChassisSpec + Motor *MotorSpec + Battery *BatterySpec + Drive *Drivetrain +} + +// UpdateCar applies a partial update. +func (s *Service) UpdateCar(ctx context.Context, id string, opts UpdateCarOptions) (CarMeta, error) { + s.mu.Lock() + c, ok := s.cars[id] + if !ok { + s.mu.Unlock() + return CarMeta{}, fmt.Errorf("%w: car %q", ErrNotFound, id) + } + if c.Visibility == VisibilitySystem { + s.mu.Unlock() + return CarMeta{}, fmt.Errorf("%w: car %q", ErrImmutableSystem, id) + } + + if opts.Name != nil { + c.Name = strings.TrimSpace(*opts.Name) + } + if opts.Visibility != nil { + if *opts.Visibility == VisibilitySystem { + s.mu.Unlock() + return CarMeta{}, fmt.Errorf("%w: cannot promote to system", ErrInvalidInput) + } + c.Visibility = *opts.Visibility + } + if opts.LengthMm != nil { + c.LengthMm = *opts.LengthMm + } + if opts.WidthMm != nil { + c.WidthMm = *opts.WidthMm + } + if opts.HeightMm != nil { + c.HeightMm = *opts.HeightMm + } + if opts.WeightG != nil { + c.WeightG = *opts.WeightG + } + if opts.TopSpeedMs != nil { + c.TopSpeedMs = *opts.TopSpeedMs + } + if opts.ColorHex != nil { + c.ColorHex = *opts.ColorHex + } + if opts.Active != nil { + c.Active = *opts.Active + } + if opts.Chassis != nil { + c.Chassis = *opts.Chassis + } + if opts.Motor != nil { + c.Motor = *opts.Motor + } + if opts.Battery != nil { + c.Battery = *opts.Battery + } + if opts.Drive != nil { + c.Drive = *opts.Drive + } + c.UpdatedMs = time.Now().UnixMilli() + + if c.LengthMm < minCarLengthMm || c.LengthMm > maxCarLengthMm || + c.WidthMm < minCarWidthMm || c.WidthMm > maxCarWidthMm { + s.mu.Unlock() + return CarMeta{}, fmt.Errorf("%w: dimensions out of range", ErrInvalidInput) + } + + updated := *c + s.mu.Unlock() + + if err := s.persistUpsertCar(ctx, updated); err != nil { + return CarMeta{}, err + } + return updated, nil +} + +// DeleteCar removes a car by id. +func (s *Service) DeleteCar(ctx context.Context, id string) error { + s.mu.RLock() + c, ok := s.cars[id] + s.mu.RUnlock() + if !ok { + return fmt.Errorf("%w: car %q", ErrNotFound, id) + } + if c.Visibility == VisibilitySystem { + return fmt.Errorf("%w: car %q", ErrImmutableSystem, id) + } + return s.persistDeleteCar(ctx, id) +} + +// SetCarStats mutates the read-mostly counters on a car (called by the +// race engine via catalog hooks). Only updates non-zero inputs. +func (s *Service) SetCarStats(ctx context.Context, id string, fn func(*CarMeta)) error { + s.mu.Lock() + c, ok := s.cars[id] + if !ok { + s.mu.Unlock() + return fmt.Errorf("%w: car %q", ErrNotFound, id) + } + fn(c) + c.UpdatedMs = time.Now().UnixMilli() + updated := *c + s.mu.Unlock() + + if s.store != nil { + if err := s.store.UpdateCarStats(ctx, id, func(c *CarMeta) { + fn(c) + c.UpdatedMs = updated.UpdatedMs + }); err != nil { + return err + } + } + s.bump(Event{Kind: EventCarUpsert, CarID: id}) + return nil +} + +// ------------------------------------------------------------------------- +// Helpers +// ------------------------------------------------------------------------- + +// getTrackLocked returns a shallow copy of a track from the cache. +// MUST be called with s.mu held. +func (s *Service) getTrackLocked(id string) (TrackMeta, bool) { + t, ok := s.tracks[id] + if !ok { + return TrackMeta{}, false + } + return *t, true +} + +func (s *Service) getCarLocked(id string) (CarMeta, bool) { + c, ok := s.cars[id] + if !ok { + return CarMeta{}, false + } + return *c, true +} + +// slugify turns "Sunset Sprint" into "sunset-sprint". +func slugify(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + prevDash := false + for _, r := range s { + switch { + case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'): + b.WriteRune(r) + prevDash = false + default: + if !prevDash && b.Len() > 0 { + b.WriteRune('-') + prevDash = true + } + } + } + out := strings.TrimRight(b.String(), "-") + if out == "" { + out = fmt.Sprintf("item-%d", time.Now().UnixNano()%1_000_000) + } + return out +} + +// computeBounds derives a Bounds from a closed loop of waypoints. +func computeBounds(loop []Waypoint) Bounds { + if len(loop) == 0 { + return Bounds{} + } + b := Bounds{ + MinX: loop[0].X, + MinY: loop[0].Y, + MaxX: loop[0].X, + MaxY: loop[0].Y, + } + for _, p := range loop[1:] { + if p.X < b.MinX { + b.MinX = p.X + } + if p.Y < b.MinY { + b.MinY = p.Y + } + if p.X > b.MaxX { + b.MaxX = p.X + } + if p.Y > b.MaxY { + b.MaxY = p.Y + } + } + b.LengthM = b.MaxX - b.MinX + b.WidthM = b.MaxY - b.MinY + return b +} + +// defaultCenterline generates a parametric oval centered in a box of +// size length_m × width_m. Used when the caller doesn't supply waypoints. +func defaultCenterline(lengthM, widthM float64) []Waypoint { + if lengthM <= 0 { + lengthM = 2.0 + } + if widthM <= 0 { + widthM = 1.2 + } + const samples = 24 + cx, cy := lengthM/2, widthM/2 + rx, ry := lengthM*0.45, widthM*0.45 + pts := make([]Waypoint, samples) + for i := 0; i < samples; i++ { + t := float64(i) / float64(samples) * 2 * math.Pi + x := cx + rx*math.Cos(t) + y := cy + ry*math.Sin(t) + // Tangent: perpendicular to the radius. + heading := t + math.Pi/2 + // Curvature: 1/r at this point (approximation using ellipse radii). + r := math.Sqrt(rx*rx*math.Sin(t)*math.Sin(t) + ry*ry*math.Cos(t)*math.Cos(t)) + curv := 0.0 + if r > 0 { + curv = math.Min(rx, ry) / (r * r) + } + // Speed: slower on tight curves. + speed := 4.0 - 2.0*math.Min(curv*5, 1) + pts[i] = Waypoint{X: x, Y: y, HeadingRad: heading, SpeedMs: speed, Curvature: curv} + } + return pts +} + +// ------------------------------------------------------------------------- +// Read APIs (Get / List / Snapshot / Stats) +// ------------------------------------------------------------------------- + +// ListTracksByVisibility returns tracks matching the given visibility. +func (s *Service) ListTracksByVisibility(v Visibility) []TrackMeta { + all := s.ListTracks() + out := all[:0:0] + for _, t := range all { + if t.Visibility == v { + out = append(out, t) + } + } + return out +} + +// ListCarsByVisibility returns cars matching the given visibility. +func (s *Service) ListCarsByVisibility(v Visibility) []CarMeta { + all := s.ListCars() + out := all[:0:0] + for _, c := range all { + if c.Visibility == v { + out = append(out, c) + } + } + return out +} + +// ListCarsByOwner returns cars owned by the given driver ID. +func (s *Service) ListCarsByOwner(ownerID string) []CarMeta { + all := s.ListCars() + out := all[:0:0] + for _, c := range all { + if c.OwnerID == ownerID { + out = append(out, c) + } + } + return out +} \ No newline at end of file diff --git a/server/internal/catalog/catalog_test.go b/server/internal/catalog/catalog_test.go new file mode 100644 index 0000000..2c1a410 --- /dev/null +++ b/server/internal/catalog/catalog_test.go @@ -0,0 +1,401 @@ +package catalog + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/x0gp/server/internal/catalog/seeddefs" +) + +// withFrozenClock is retained for compatibility with older tests that +// depended on deterministic timestamps. The Service no longer reads +// timeNowMillis directly; timestamps come from time.Now() inside +// normalizeTrack / normalizeCar, but those are only filled when +// CreatedMs / UpdatedMs are zero. +func withFrozenClock(t *testing.T) { + t.Helper() + // Intentionally a no-op; tests below no longer need clock control + // because the Service relies on time.Now() and we just check ranges. +} + +// loadSeeds is a helper that converts seeddefs into catalog types and +// loads them into a memoryStore for use in tests. +func loadSeeds() (*Service, error) { + store := newMemoryStore() + ctx := context.Background() + for _, t := range seeddefs.DefaultTracks() { + cm, err := seedTrackToCatalog(t) + if err != nil { + return nil, err + } + if err := store.UpsertTrack(ctx, cm); err != nil { + return nil, err + } + } + for _, c := range seeddefs.DefaultCars() { + cm := seedCarToCatalog(c) + if err := store.UpsertCar(ctx, cm); err != nil { + return nil, err + } + } + return NewService(ctx, store) +} + +func seedTrackToCatalog(t seeddefs.Track) (TrackMeta, error) { + now := time.Now().UnixMilli() + return TrackMeta{ + ID: t.ID, + Name: t.Name, + Description: t.Description, + AuthorID: t.AuthorID, + Visibility: Visibility(t.Visibility), + Scale: t.Scale, + LengthM: t.LengthM, + WidthM: t.WidthM, + LaneWidthM: t.LaneWidthM, + Bounds: Bounds{ + MinX: t.Bounds.MinX, MinY: t.Bounds.MinY, + MaxX: t.Bounds.MaxX, MaxY: t.Bounds.MaxY, + LengthM: t.Bounds.LengthM, WidthM: t.Bounds.WidthM, + }, + Surface: Surface(t.Surface), + Tags: append([]string(nil), t.Tags...), + Centerline: waypointsFromSeed(t.Centerline), + BestLapMs: t.BestLapMs, + CreatedMs: now, + UpdatedMs: now, + }, nil +} + +func seedCarToCatalog(c seeddefs.Car) CarMeta { + now := time.Now().UnixMilli() + return CarMeta{ + ID: c.ID, + Name: c.Name, + OwnerID: c.OwnerID, + Visibility: Visibility(c.Visibility), + Scale: c.Scale, + LengthMm: c.LengthMm, + WidthMm: c.WidthMm, + HeightMm: c.HeightMm, + WeightG: c.WeightG, + Chassis: ChassisSpec{ + Model: c.Chassis.Model, Material: c.Chassis.Material, + Printed: c.Chassis.Printed, WheelbaseMm: c.Chassis.WheelbaseMm, + TrackMm: c.Chassis.TrackMm, + }, + Motor: MotorSpec{ + Kind: c.Motor.Kind, Class: c.Motor.Class, + KV: c.Motor.KV, PowerW: c.Motor.PowerW, + }, + Battery: BatterySpec{ + VoltageV: c.Battery.VoltageV, CapacityMah: c.Battery.CapacityMah, + Cells: c.Battery.Cells, Chemistry: c.Battery.Chemistry, + }, + Drive: Drivetrain(c.Drive), + TopSpeedMs: c.TopSpeedMs, + ColorHex: c.ColorHex, + Active: c.Active, + TotalRaces: c.TotalRaces, + TotalLaps: c.TotalLaps, + BestLapMs: c.BestLapMs, + CreatedMs: now, + UpdatedMs: now, + } +} + +func waypointsFromSeed(in []seeddefs.Waypoint) []Waypoint { + if in == nil { + return nil + } + out := make([]Waypoint, len(in)) + for i, w := range in { + out[i] = Waypoint{ + X: w.X, Y: w.Y, HeadingRad: w.HeadingRad, + SpeedMs: w.SpeedMs, Curvature: w.Curvature, + } + } + return out +} + +func TestSeedsFitInRoom(t *testing.T) { + tracks := seeddefs.DefaultTracks() + cars := seeddefs.DefaultCars() + if len(tracks) == 0 { + t.Fatal("no default tracks") + } + if len(cars) == 0 { + t.Fatal("no default cars") + } + const ( + roomLen = 4.46 + roomWid = 3.19 + ) + for _, tr := range tracks { + if tr.Bounds.LengthM > roomLen { + t.Errorf("track %s: length %.2f m exceeds room 4.46 m", tr.ID, tr.Bounds.LengthM) + } + if tr.Bounds.WidthM > roomWid { + t.Errorf("track %s: width %.2f m exceeds room 3.19 m", tr.ID, tr.Bounds.WidthM) + } + if len(tr.Centerline) < 8 { + t.Errorf("track %s: centerline too short (%d)", tr.ID, len(tr.Centerline)) + } + } + for _, c := range cars { + if c.Scale != 24 { + t.Errorf("car %s: scale=%d, expected 24 (F1 1/24)", c.ID, c.Scale) + } + if c.Visibility != "system" { + t.Errorf("car %s: not system visibility", c.ID) + } + if c.LengthMm < 200 || c.LengthMm > 260 { + t.Errorf("car %s: length %.0f mm out of F1 1/24 range", c.ID, c.LengthMm) + } + } +} + +func TestCreateAndGetTrack(t *testing.T) { + s, err := loadSeeds() + if err != nil { + t.Fatalf("loadSeeds: %v", err) + } + defer s.Stop() + + got, ok := s.GetTrack("monaco") + if !ok { + t.Fatal("expected to find monaco") + } + if got.ID != "monaco" { + t.Errorf("got id %q", got.ID) + } +} + +func TestCreateUserTrack(t *testing.T) { + s, err := loadSeeds() + if err != nil { + t.Fatalf("loadSeeds: %v", err) + } + defer s.Stop() + ctx := context.Background() + + tr, err := s.CreateTrack(ctx, CreateTrackOptions{ + Name: "My Garage", + AuthorID: "driver-1", + Visibility: VisibilityPublic, + LengthM: 3.5, + WidthM: 2.2, + Surface: SurfaceTile, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if tr.Visibility != VisibilityPublic { + t.Errorf("got visibility %q", tr.Visibility) + } + if tr.AuthorID != "driver-1" { + t.Errorf("got author %q", tr.AuthorID) + } + if tr.ID != "my-garage" { + t.Errorf("slug: got %q", tr.ID) + } +} + +func TestCreateRejectsOversizedTrack(t *testing.T) { + s, _ := loadSeeds() + defer s.Stop() + ctx := context.Background() + + _, err := s.CreateTrack(ctx, CreateTrackOptions{ + Name: "Too Big", + LengthM: 6.0, + WidthM: 4.0, + }) + if err == nil { + t.Fatal("expected error for oversized track") + } +} + +func TestUpdateSystemTrackFails(t *testing.T) { + s, _ := loadSeeds() + defer s.Stop() + ctx := context.Background() + + vis := VisibilityPublic + _, err := s.UpdateTrack(ctx, "monaco", UpdateTrackOptions{Visibility: &vis}) + if err == nil { + t.Fatal("expected error when mutating system track") + } +} + +func TestDeleteUserTrack(t *testing.T) { + s, _ := loadSeeds() + defer s.Stop() + ctx := context.Background() + + tr, _ := s.CreateTrack(ctx, CreateTrackOptions{Name: "Temp", Visibility: VisibilityPrivate}) + if err := s.DeleteTrack(ctx, tr.ID); err != nil { + t.Fatalf("delete: %v", err) + } + if _, ok := s.GetTrack(tr.ID); ok { + t.Fatal("track still present") + } +} + +func TestDeleteSystemTrackFails(t *testing.T) { + s, _ := loadSeeds() + defer s.Stop() + ctx := context.Background() + + if err := s.DeleteTrack(ctx, "monaco"); err == nil { + t.Fatal("expected error when deleting system track") + } +} + +func TestCreateAndUpdateCar(t *testing.T) { + s, _ := loadSeeds() + defer s.Stop() + ctx := context.Background() + + car, err := s.CreateCar(ctx, CreateCarOptions{ + Name: "Bumblebee", + OwnerID: "driver-1", + LengthMm: 78, + WidthMm: 34, + TopSpeedMs: 4.5, + ColorHex: "#d29922", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if car.ID != "bumblebee" { + t.Errorf("slug: got %q", car.ID) + } + + color := "#3fb950" + updated, err := s.UpdateCar(ctx, car.ID, UpdateCarOptions{ColorHex: &color}) + if err != nil { + t.Fatalf("update: %v", err) + } + if updated.ColorHex != color { + t.Errorf("got color %q", updated.ColorHex) + } +} + +func TestCreateCarRejectsInvalidDims(t *testing.T) { + s, _ := loadSeeds() + defer s.Stop() + ctx := context.Background() + + _, err := s.CreateCar(ctx, CreateCarOptions{Name: "Tiny", LengthMm: 10, WidthMm: 5}) + if err == nil { + t.Fatal("expected error for too-small car") + } + _, err = s.CreateCar(ctx, CreateCarOptions{Name: "Big", LengthMm: 500, WidthMm: 200}) + if err == nil { + t.Fatal("expected error for too-big car") + } +} + +func TestSubscribeReceivesChange(t *testing.T) { + s, _ := loadSeeds() + defer s.Stop() + ctx := context.Background() + + ch, cancel := s.Subscribe() + defer cancel() + <-ch // drain initial snapshot + + _, err := s.CreateTrack(ctx, CreateTrackOptions{Name: "E2E", Visibility: VisibilityPublic}) + if err != nil { + t.Fatalf("create: %v", err) + } + + select { + case ev := <-ch: + if ev.Kind != EventTrackUpsert { + t.Fatalf("kind=%s", ev.Kind) + } + if ev.TrackID != "e2e" { + t.Errorf("track_id=%s", ev.TrackID) + } + case <-time.After(time.Second): + t.Fatal("no event") + } +} + +func TestStatsCounters(t *testing.T) { + s, _ := loadSeeds() + defer s.Stop() + + st := s.Stats() + if st.TracksSystem < 5 { + t.Errorf("expected at least 5 system tracks, got %d", st.TracksSystem) + } + if st.CarsSystem < 5 { + t.Errorf("expected at least 5 system cars, got %d", st.CarsSystem) + } +} + +func TestConcurrentCreatesSafe(t *testing.T) { + store := newMemoryStore() + s, err := NewService(context.Background(), store) + if err != nil { + t.Fatalf("new: %v", err) + } + defer s.Stop() + ctx := context.Background() + const n = 20 + var wg sync.WaitGroup + wg.Add(n) + for i := 0; i < n; i++ { + go func(i int) { + defer wg.Done() + name := "Concurrent" + _, _ = s.CreateTrack(ctx, CreateTrackOptions{ + Name: name, + LengthM: 2.0, + WidthM: 1.2, + }) + }(i) + } + wg.Wait() + if got := len(s.ListTracks()); got != 1 { + t.Errorf("expected 1 track, got %d", got) + } +} + +func TestSetCarStatsUpdatesFields(t *testing.T) { + s, _ := loadSeeds() + defer s.Stop() + ctx := context.Background() + + err := s.SetCarStats(ctx, "f1-2024-redbull-rb20", func(c *CarMeta) { + c.TotalLaps = 12 + c.BestLapMs = 9450 + c.TotalDistanceM = 320.5 + }) + if err != nil { + t.Fatalf("set stats: %v", err) + } + got, _ := s.GetCar("f1-2024-redbull-rb20") + if got.TotalLaps != 12 || got.BestLapMs != 9450 || got.TotalDistanceM != 320.5 { + t.Errorf("stats not updated: %+v", got) + } +} + +func TestServiceWithoutStore(t *testing.T) { + s, err := NewService(context.Background(), nil) + if err != nil { + t.Fatalf("new: %v", err) + } + defer s.Stop() + ctx := context.Background() + + _, err = s.CreateTrack(ctx, CreateTrackOptions{Name: "X", LengthM: 1.5, WidthM: 1.0}) + if err == nil { + t.Fatal("expected error when store is nil") + } +} \ No newline at end of file diff --git a/server/internal/catalog/seeddefs/seeddefs.go b/server/internal/catalog/seeddefs/seeddefs.go new file mode 100644 index 0000000..0d64f05 --- /dev/null +++ b/server/internal/catalog/seeddefs/seeddefs.go @@ -0,0 +1,614 @@ +// 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. +package seeddefs + +import "math" + +// Default tracks and cars shipped with the PoC. All entries are +// visibility=system and therefore immutable from the API. +// +// Tracks: five real F1 circuits (2024 calendar), simplified to fit a +// 4.5 × 3.2 m bounding box at 1/27 scale. Each track keeps a +// recognisable shape and characteristic corners. +// +// Cars: five real F1 2024 teams. Length / width / weight are real +// (1/24 scale), power figures are real (full-scale 1.6L V6 hybrid +// turbo, ~1000 hp combined MGU-K + ICE). +func DefaultTracks() []Track { + return []Track{ + Monaco(), + BarcelonaCatalunya(), + AustriaRedBullRing(), + GreatBritainSilverstone(), + BelgiumSpa(), + } +} + +func DefaultCars() []Car { + return []Car{ + RedBullRB20(), + FerrariSF24(), + McLarenMCL38(), + MercedesW15(), + AstonMartinAMR24(), + } +} + +// --------------------------------------------------------------------------- +// Tracks +// +// Coordinates: lower-left origin, metres. Bounds must fit inside a +// 4.5 × 3.2 m room. +// +// Conventions: +// - closed loop, samples[i] and samples[0] are not duplicated +// - speed_ms is a suggested cornering speed (lower = tighter) +// - curvature = 1/radius; 0 = straight +// --------------------------------------------------------------------------- + +// Monaco — narrow street circuit, hairpins, tight corners. +// Length ≈ 3.337 km IRL → simplified to 4.0 × 2.6 m. +func Monaco() Track { + const ( + lengthM = 4.0 + widthM = 2.6 + ) + // Famous corners: Sainte-Devote, Casino Square, Mirabeau, Loews hairpin, + // Portier, Tunnel, Nouvelle chicane, Tabac, Swimming pool, Rascasse, Anthony Noghes. + // Lower speeds through Monaco → 1.8..3.2 m/s. + wps := []Waypoint{ + // Start/finish straight (Sainte-Devote approach). + {X: 0.4, Y: 2.2, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0}, + {X: 1.1, Y: 2.2, HeadingRad: 0, SpeedMs: 3.0, Curvature: 0}, + // Sainte-Devote (right kink). + {X: 1.5, Y: 2.05, HeadingRad: -0.4, SpeedMs: 2.4, Curvature: 1.2}, + {X: 1.7, Y: 1.85, HeadingRad: -0.9, SpeedMs: 2.2, Curvature: 1.8}, + // Up the hill to Casino / Massenet. + {X: 1.55, Y: 1.55, HeadingRad: -1.6, SpeedMs: 2.0, Curvature: 1.4}, + {X: 1.25, Y: 1.4, HeadingRad: -2.4, SpeedMs: 2.1, Curvature: 1.2}, + // Casino Square (slow left). + {X: 0.95, Y: 1.55, HeadingRad: math.Pi, SpeedMs: 1.9, Curvature: 1.6}, + // Mirabeau hairpin (right). + {X: 0.75, Y: 1.85, HeadingRad: 2.0, SpeedMs: 1.8, Curvature: 2.2}, + // Loews hairpin (tight left). + {X: 0.95, Y: 2.15, HeadingRad: 2.6, SpeedMs: 1.8, Curvature: 2.6}, + {X: 1.2, Y: 2.4, HeadingRad: -2.8, SpeedMs: 2.1, Curvature: 1.4}, + // Portier (right kink onto the straight). + {X: 1.6, Y: 2.5, HeadingRad: -3.0, SpeedMs: 2.4, Curvature: 1.0}, + // Tunnel straight. + {X: 2.6, Y: 2.5, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0}, + {X: 3.4, Y: 2.5, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0}, + // Nouvelle chicane (fast left-right). + {X: 3.6, Y: 2.25, HeadingRad: -0.5, SpeedMs: 2.6, Curvature: 1.5}, + {X: 3.55, Y: 1.95, HeadingRad: 0.4, SpeedMs: 2.6, Curvature: 1.5}, + // Tabac (right). + {X: 3.3, Y: 1.75, HeadingRad: 1.0, SpeedMs: 2.3, Curvature: 1.4}, + // Swimming pool (left-right-left). + {X: 2.95, Y: 1.55, HeadingRad: 1.7, SpeedMs: 2.1, Curvature: 1.8}, + {X: 2.6, Y: 1.65, HeadingRad: 2.6, SpeedMs: 2.1, Curvature: 1.8}, + {X: 2.4, Y: 1.9, HeadingRad: -2.7, SpeedMs: 2.3, Curvature: 1.4}, + // Rascasse (left). + {X: 2.4, Y: 2.2, HeadingRad: -3.0, SpeedMs: 2.2, Curvature: 1.6}, + // Anthony Noghes (left onto pit straight). + {X: 2.6, Y: 2.4, HeadingRad: 2.8, SpeedMs: 2.5, Curvature: 1.0}, + // Pit straight (back to start). + {X: 3.2, Y: 2.4, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0}, + {X: 3.7, Y: 2.4, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0}, + {X: 3.95, Y: 2.3, HeadingRad: -0.3, SpeedMs: 2.8, Curvature: 0.8}, + } + b := ComputeBounds(wps) + return Track{ + ID: "monaco", + Name: "Monaco", + Description: "Circuit de Monaco. Narrow street circuit, tight hairpins, no room for error.", + AuthorID: "system", + Visibility: "system", + Scale: 27, + LengthM: b.LengthM, + WidthM: b.WidthM, + LaneWidthM: 0.18, + Surface: "tile", + Tags: []string{"f1", "street", "tight", "slow", "monaco"}, + Centerline: wps, + Bounds: b, + BestLapHolder: "Verstappen", + } +} + +// BarcelonaCatalunya — mix of high-speed and technical, used for testing. +// Length ≈ 4.675 km IRL → 4.3 × 2.9 m. +func BarcelonaCatalunya() Track { + const ( + lengthM = 4.3 + widthM = 2.9 + ) + wps := []Waypoint{ + // Main straight. + {X: 0.3, Y: 0.4, HeadingRad: 0, SpeedMs: 4.5, Curvature: 0}, + {X: 1.4, Y: 0.4, HeadingRad: 0, SpeedMs: 4.5, Curvature: 0}, + // Turn 1 — Elf (right hairpin). + {X: 1.7, Y: 0.65, HeadingRad: math.Pi / 2, SpeedMs: 2.2, Curvature: 1.6}, + {X: 1.5, Y: 0.95, HeadingRad: math.Pi, SpeedMs: 2.0, Curvature: 2.0}, + // Turn 2-3 — right kink. + {X: 1.2, Y: 1.1, HeadingRad: math.Pi + 0.3, SpeedMs: 2.6, Curvature: 1.0}, + {X: 0.95, Y: 1.0, HeadingRad: math.Pi + 0.9, SpeedMs: 2.6, Curvature: 1.0}, + // Turn 4 — Repsol (left). + {X: 0.8, Y: 1.2, HeadingRad: -math.Pi / 2, SpeedMs: 2.4, Curvature: 1.2}, + // Turn 5 — fast right. + {X: 1.05, Y: 1.5, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0.7}, + // Turn 7-8 — slow left-right. + {X: 1.3, Y: 1.65, HeadingRad: math.Pi / 2, SpeedMs: 2.4, Curvature: 1.0}, + {X: 1.15, Y: 1.85, HeadingRad: math.Pi, SpeedMs: 2.4, Curvature: 1.0}, + // Turn 9-10 — Campsa (fast right). + {X: 1.4, Y: 2.0, HeadingRad: math.Pi - 0.5, SpeedMs: 3.0, Curvature: 0.7}, + {X: 1.7, Y: 2.0, HeadingRad: math.Pi - 1.0, SpeedMs: 3.0, Curvature: 0.7}, + // Turn 12-13 — long right. + {X: 1.95, Y: 2.2, HeadingRad: math.Pi / 2, SpeedMs: 2.8, Curvature: 0.5}, + {X: 1.65, Y: 2.5, HeadingRad: 0.3, SpeedMs: 2.8, Curvature: 0.5}, + // Turn 14-15 — fast right curve. + {X: 2.2, Y: 2.7, HeadingRad: 0.7, SpeedMs: 3.2, Curvature: 0.5}, + {X: 2.8, Y: 2.7, HeadingRad: 1.1, SpeedMs: 3.4, Curvature: 0.5}, + // Turn 16 — La Caixa (right hairpin). + {X: 3.1, Y: 2.5, HeadingRad: math.Pi / 2 + 0.4, SpeedMs: 2.2, Curvature: 1.4}, + {X: 2.95, Y: 2.2, HeadingRad: math.Pi + 0.4, SpeedMs: 2.2, Curvature: 1.4}, + // Back straight. + {X: 3.3, Y: 1.9, HeadingRad: 0, SpeedMs: 4.0, Curvature: 0}, + {X: 4.0, Y: 1.9, HeadingRad: 0, SpeedMs: 4.0, Curvature: 0}, + // Turn 16bis-17 (chicane before start). + {X: 4.05, Y: 1.6, HeadingRad: -math.Pi / 2, SpeedMs: 2.4, Curvature: 1.4}, + {X: 3.8, Y: 1.4, HeadingRad: math.Pi, SpeedMs: 2.4, Curvature: 1.4}, + {X: 3.5, Y: 1.5, HeadingRad: math.Pi + math.Pi/4, SpeedMs: 2.6, Curvature: 1.0}, + // Approach to start. + {X: 3.1, Y: 1.25, HeadingRad: -math.Pi / 2 - 0.4, SpeedMs: 3.0, Curvature: 0.7}, + {X: 2.7, Y: 1.0, HeadingRad: -math.Pi / 2 - 0.9, SpeedMs: 3.0, Curvature: 0.7}, + {X: 2.3, Y: 0.7, HeadingRad: math.Pi + 0.4, SpeedMs: 3.4, Curvature: 0.5}, + {X: 1.6, Y: 0.5, HeadingRad: math.Pi + 0.2, SpeedMs: 3.6, Curvature: 0.4}, + {X: 0.9, Y: 0.42, HeadingRad: 0, SpeedMs: 4.0, Curvature: 0}, + } + b := ComputeBounds(wps) + return Track{ + ID: "barcelona-catalunya", + Name: "Barcelona-Catalunya", + Description: "Circuit de Barcelona-Catalunya. Mix of high-speed corners and technical chicanes.", + AuthorID: "system", + Visibility: "system", + Scale: 27, + LengthM: b.LengthM, + WidthM: b.WidthM, + LaneWidthM: 0.18, + Surface: "tile", + Tags: []string{"f1", "balanced", "testing", "barcelona"}, + Centerline: wps, + Bounds: b, + BestLapHolder: "Verstappen", + } +} + +// AustriaRedBullRing — short, fast, lots of elevation IRL. +// Length ≈ 4.318 km IRL → 4.0 × 2.4 m. +func AustriaRedBullRing() Track { + const ( + lengthM = 4.0 + widthM = 2.4 + ) + wps := []Waypoint{ + // Main straight. + {X: 0.3, Y: 0.4, HeadingRad: 0, SpeedMs: 4.8, Curvature: 0}, + {X: 1.6, Y: 0.4, HeadingRad: 0, SpeedMs: 4.8, Curvature: 0}, + // Turn 1 — right hairpin. + {X: 1.85, Y: 0.65, HeadingRad: math.Pi / 2, SpeedMs: 2.0, Curvature: 1.8}, + {X: 1.6, Y: 0.95, HeadingRad: math.Pi, SpeedMs: 2.0, Curvature: 1.8}, + // Turn 2 — fast right. + {X: 1.3, Y: 1.0, HeadingRad: math.Pi + 0.5, SpeedMs: 3.0, Curvature: 0.7}, + // Turn 3 — left. + {X: 1.05, Y: 1.2, HeadingRad: -math.Pi / 2, SpeedMs: 2.6, Curvature: 1.0}, + // Turn 4 — Remus (fast uphill right). + {X: 1.4, Y: 1.4, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0.5}, + {X: 1.85, Y: 1.4, HeadingRad: math.Pi / 2 + 0.2, SpeedMs: 3.2, Curvature: 0.5}, + // Turn 5-6 — fast right-left. + {X: 2.1, Y: 1.65, HeadingRad: math.Pi, SpeedMs: 3.0, Curvature: 0.7}, + {X: 2.0, Y: 1.85, HeadingRad: -math.Pi / 2, SpeedMs: 3.0, Curvature: 0.7}, + // Turn 7 — right hairpin. + {X: 2.25, Y: 2.05, HeadingRad: 0, SpeedMs: 2.0, Curvature: 1.8}, + {X: 2.4, Y: 2.0, HeadingRad: math.Pi / 2, SpeedMs: 2.0, Curvature: 1.8}, + // Turn 8 — left hairpin. + {X: 2.4, Y: 2.2, HeadingRad: math.Pi, SpeedMs: 2.0, Curvature: 1.8}, + {X: 2.2, Y: 2.3, HeadingRad: -math.Pi / 2, SpeedMs: 2.0, Curvature: 1.8}, + // Turn 9 — back straight start (left kink). + {X: 2.55, Y: 2.4, HeadingRad: -0.2, SpeedMs: 3.4, Curvature: 0.4}, + {X: 3.0, Y: 2.4, HeadingRad: 0, SpeedMs: 4.0, Curvature: 0}, + {X: 3.5, Y: 2.4, HeadingRad: 0, SpeedMs: 4.5, Curvature: 0}, + // Turn 10 — fast right. + {X: 3.85, Y: 2.15, HeadingRad: math.Pi / 2 + 0.2, SpeedMs: 3.4, Curvature: 0.6}, + // Back to pit straight via Schumacher esses. + {X: 3.6, Y: 1.8, HeadingRad: math.Pi, SpeedMs: 3.0, Curvature: 0.8}, + {X: 3.25, Y: 1.85, HeadingRad: math.Pi + 0.6, SpeedMs: 3.0, Curvature: 0.8}, + {X: 3.0, Y: 1.6, HeadingRad: -math.Pi / 2 - 0.4, SpeedMs: 3.2, Curvature: 0.6}, + {X: 2.6, Y: 1.4, HeadingRad: -math.Pi, SpeedMs: 3.4, Curvature: 0.5}, + {X: 2.0, Y: 1.2, HeadingRad: math.Pi - 0.5, SpeedMs: 3.6, Curvature: 0.4}, + {X: 1.4, Y: 0.8, HeadingRad: math.Pi - 0.2, SpeedMs: 3.8, Curvature: 0.3}, + {X: 0.8, Y: 0.5, HeadingRad: 0, SpeedMs: 4.2, Curvature: 0.2}, + } + b := ComputeBounds(wps) + return Track{ + ID: "austria-redbull-ring", + Name: "Austria (Red Bull Ring)", + Description: "Red Bull Ring. Short, fast, three straights separated by hairpins.", + AuthorID: "system", + Visibility: "system", + Scale: 27, + LengthM: b.LengthM, + WidthM: b.WidthM, + LaneWidthM: 0.18, + Surface: "wood", + Tags: []string{"f1", "short", "fast", "austria"}, + Centerline: wps, + Bounds: b, + BestLapHolder: "Verstappen", + } +} + +// GreatBritainSilverstone — fast, flowing, essex corners. +// Length ≈ 5.891 km IRL → 4.4 × 3.0 m. +func GreatBritainSilverstone() Track { + const ( + lengthM = 4.4 + widthM = 3.0 + ) + wps := []Waypoint{ + // Main straight (Wellington / pit straight), bottom of room. + {X: 0.3, Y: 0.3, HeadingRad: 0, SpeedMs: 5.0, Curvature: 0}, + {X: 2.4, Y: 0.3, HeadingRad: 0, SpeedMs: 5.0, Curvature: 0}, + // Turn 1 — Abbey (right). + {X: 2.8, Y: 0.45, HeadingRad: math.Pi / 2, SpeedMs: 3.0, Curvature: 0.8}, + // Turn 2 — Farm Curve (left). + {X: 2.6, Y: 0.8, HeadingRad: math.Pi, SpeedMs: 3.4, Curvature: 0.6}, + // Turn 3 — Village (right kink). + {X: 2.95, Y: 1.05, HeadingRad: math.Pi / 2 + 0.5, SpeedMs: 3.2, Curvature: 0.7}, + // Turn 4 — The Loop (right hairpin). + {X: 3.3, Y: 1.3, HeadingRad: 0, SpeedMs: 2.2, Curvature: 1.6}, + {X: 3.6, Y: 1.15, HeadingRad: -math.Pi / 2, SpeedMs: 2.2, Curvature: 1.6}, + // Turn 5 — Aintree (fast left). + {X: 3.6, Y: 0.8, HeadingRad: -math.Pi, SpeedMs: 3.0, Curvature: 0.7}, + // Turn 6 — Brooklands (right). + {X: 3.85, Y: 0.55, HeadingRad: -math.Pi / 2, SpeedMs: 2.6, Curvature: 1.0}, + // Turn 7 — Luffield (long right). + {X: 3.55, Y: 0.25, HeadingRad: 0, SpeedMs: 2.4, Curvature: 1.0}, + {X: 3.25, Y: 0.25, HeadingRad: math.Pi / 2, SpeedMs: 2.4, Curvature: 1.0}, + // Turn 8-9 — Woodcote (right kink onto pit straight). + {X: 3.05, Y: 0.55, HeadingRad: math.Pi / 2 + 0.3, SpeedMs: 3.4, Curvature: 0.5}, + {X: 2.85, Y: 0.45, HeadingRad: math.Pi, SpeedMs: 4.0, Curvature: 0.3}, + // (mid-pit straight into Copse corner) + {X: 1.8, Y: 0.3, HeadingRad: 0, SpeedMs: 4.6, Curvature: 0}, + // Copse (fast right). + {X: 1.2, Y: 0.45, HeadingRad: -math.Pi / 2 - 0.2, SpeedMs: 3.8, Curvature: 0.4}, + // Maggotts (right curve, swept). + {X: 1.0, Y: 0.85, HeadingRad: -math.Pi - 0.4, SpeedMs: 3.6, Curvature: 0.5}, + // Beckets (left hairpin-ish). + {X: 0.7, Y: 1.15, HeadingRad: math.Pi / 2 + 0.3, SpeedMs: 2.4, Curvature: 1.2}, + {X: 0.5, Y: 0.9, HeadingRad: 0, SpeedMs: 2.6, Curvature: 0.9}, + // Chapel (right kink). + {X: 0.7, Y: 0.65, HeadingRad: -math.Pi / 2, SpeedMs: 3.0, Curvature: 0.7}, + // Hangar (left). + {X: 0.45, Y: 0.45, HeadingRad: -math.Pi - 0.4, SpeedMs: 2.6, Curvature: 1.0}, + // Stowe (right). + {X: 0.75, Y: 1.0, HeadingRad: math.Pi / 2 - 0.4, SpeedMs: 2.4, Curvature: 1.0}, + // Vale (left kink). + {X: 0.5, Y: 1.5, HeadingRad: -math.Pi - 0.4, SpeedMs: 2.6, Curvature: 0.8}, + // Club (right kink onto Wellington). + {X: 0.3, Y: 1.15, HeadingRad: -math.Pi / 2, SpeedMs: 2.4, Curvature: 1.0}, + // Top return straight + kink back to Wellington. + {X: 0.7, Y: 1.6, HeadingRad: math.Pi / 4, SpeedMs: 3.4, Curvature: 0.5}, + {X: 2.0, Y: 1.95, HeadingRad: math.Pi / 2 + 0.4, SpeedMs: 3.0, Curvature: 0.7}, + {X: 3.0, Y: 2.4, HeadingRad: math.Pi + 0.2, SpeedMs: 3.4, Curvature: 0.4}, + {X: 2.4, Y: 2.65, HeadingRad: -math.Pi / 2 - 0.3, SpeedMs: 4.0, Curvature: 0.3}, + {X: 1.4, Y: 2.6, HeadingRad: -math.Pi, SpeedMs: 4.4, Curvature: 0.2}, + {X: 0.5, Y: 2.3, HeadingRad: -math.Pi + 0.3, SpeedMs: 4.8, Curvature: 0.2}, + {X: 0.3, Y: 1.7, HeadingRad: -math.Pi / 2 + 0.3, SpeedMs: 5.0, Curvature: 0.1}, + } + b := ComputeBounds(wps) + return Track{ + ID: "great-britain-silverstone", + Name: "Great Britain (Silverstone)", + Description: "Silverstone Circuit. Fast, flowing corners; home of British motorsport.", + AuthorID: "system", + Visibility: "system", + Scale: 27, + LengthM: b.LengthM, + WidthM: b.WidthM, + LaneWidthM: 0.18, + Surface: "carpet", + Tags: []string{"f1", "fast", "flowing", "silverstone"}, + Centerline: wps, + Bounds: b, + BestLapHolder: "Hamilton", + } +} + +// BelgiumSpa — long, fast, Eau Rouge / Raidillon, varied weather. +// Length ≈ 7.004 km IRL → 4.5 × 3.0 m. +func BelgiumSpa() Track { + const ( + lengthM = 4.5 + widthM = 3.0 + ) + wps := []Waypoint{ + // Main straight (La Source exit). + {X: 0.4, Y: 0.5, HeadingRad: 0, SpeedMs: 4.8, Curvature: 0}, + {X: 1.6, Y: 0.5, HeadingRad: 0, SpeedMs: 4.8, Curvature: 0}, + // Eau Rouge / Raidillon (fast uphill left-right-left). + {X: 1.95, Y: 0.6, HeadingRad: 0.3, SpeedMs: 4.2, Curvature: 0.5}, + {X: 2.2, Y: 0.95, HeadingRad: 0.9, SpeedMs: 4.0, Curvature: 0.6}, + {X: 2.1, Y: 1.3, HeadingRad: math.Pi + 0.4, SpeedMs: 4.0, Curvature: 0.6}, + // Kemmel straight. + {X: 2.7, Y: 1.5, HeadingRad: 0, SpeedMs: 5.0, Curvature: 0}, + {X: 3.5, Y: 1.5, HeadingRad: 0, SpeedMs: 5.0, Curvature: 0}, + // Les Combes (chicane). + {X: 3.85, Y: 1.3, HeadingRad: -math.Pi / 2, SpeedMs: 2.4, Curvature: 1.4}, + {X: 3.6, Y: 1.1, HeadingRad: math.Pi + 0.3, SpeedMs: 2.6, Curvature: 1.2}, + {X: 3.3, Y: 1.25, HeadingRad: math.Pi + 0.9, SpeedMs: 2.6, Curvature: 1.2}, + // Malmedy (fast right sweep). + {X: 3.5, Y: 1.55, HeadingRad: math.Pi / 2, SpeedMs: 3.6, Curvature: 0.4}, + // Rivage (right hairpin). + {X: 3.25, Y: 1.85, HeadingRad: 0, SpeedMs: 2.2, Curvature: 1.6}, + {X: 3.05, Y: 1.7, HeadingRad: -math.Pi / 2, SpeedMs: 2.4, Curvature: 1.4}, + // Campus (left kink). + {X: 3.25, Y: 1.5, HeadingRad: 0, SpeedMs: 3.0, Curvature: 0.7}, + // Stavelot (fast right). + {X: 3.7, Y: 1.65, HeadingRad: math.Pi / 2 + 0.3, SpeedMs: 3.4, Curvature: 0.5}, + // Up to Blanchimont (long right). + {X: 4.1, Y: 2.0, HeadingRad: math.Pi / 2 + 0.6, SpeedMs: 4.0, Curvature: 0.3}, + // Blanchimont (very fast right kink). + {X: 4.25, Y: 2.4, HeadingRad: math.Pi + 0.4, SpeedMs: 4.6, Curvature: 0.3}, + // Bus stop chicane (slow left-right). + {X: 4.0, Y: 2.7, HeadingRad: -math.Pi / 2 - 0.3, SpeedMs: 2.4, Curvature: 1.4}, + {X: 3.7, Y: 2.55, HeadingRad: math.Pi, SpeedMs: 2.4, Curvature: 1.4}, + {X: 3.55, Y: 2.75, HeadingRad: math.Pi + 0.7, SpeedMs: 2.4, Curvature: 1.4}, + // Back to La Source hairpin. + {X: 3.05, Y: 2.7, HeadingRad: -math.Pi / 2, SpeedMs: 2.0, Curvature: 1.8}, + {X: 2.85, Y: 2.45, HeadingRad: 0, SpeedMs: 2.0, Curvature: 1.8}, + // Pit straight. + {X: 2.0, Y: 2.2, HeadingRad: math.Pi - 0.3, SpeedMs: 3.6, Curvature: 0.3}, + {X: 1.2, Y: 1.6, HeadingRad: math.Pi - 0.6, SpeedMs: 4.0, Curvature: 0.2}, + {X: 0.6, Y: 1.05, HeadingRad: math.Pi - 0.3, SpeedMs: 4.4, Curvature: 0.2}, + } + b := ComputeBounds(wps) + return Track{ + ID: "belgium-spa", + Name: "Belgium (Spa-Francorchamps)", + Description: "Spa-Francorchamps. Long, fast, legendary Eau Rouge / Raidillon.", + AuthorID: "system", + Visibility: "system", + Scale: 27, + LengthM: b.LengthM, + WidthM: b.WidthM, + LaneWidthM: 0.18, + Surface: "carpet", + Tags: []string{"f1", "long", "fast", "spa"}, + Centerline: wps, + Bounds: b, + BestLapHolder: "Verstappen", + } +} + +// --------------------------------------------------------------------------- +// Cars — F1 2024 constructors +// +// Real cars are 1:1 with ~5.7 × 2.0 × 0.95 m, ~798 kg, ~1000 hp. +// We model them at 1/24 scale so the largest dimension (~238 mm) still +// fits the catalog physical-envelope check. +// --------------------------------------------------------------------------- + +// RedBullRB20 — Oracle Red Bull Racing, Verstappen / Perez. +// 2024: 9 wins (last year of dominant era), Constructors' champion. +func RedBullRB20() Car { + return Car{ + ID: "f1-2024-redbull-rb20", + Name: "Oracle Red Bull Racing RB20", + OwnerID: "system", + Visibility: "system", + Scale: 24, + LengthMm: 238, // 5.7m IRL + WidthMm: 83, // 2.0m IRL + HeightMm: 40, // 0.95m IRL + WeightG: 33, // 798 kg IRL + Chassis: Chassis{ + Model: "RB20 carbon monocoque", + Material: "carbon-fibre", + Printed: false, + WheelbaseMm: 150, // 3.6m IRL + TrackMm: 75, // 1.8m IRL + }, + Motor: Motor{ + Kind: "turbo-hybrid", + Class: "F1 PU", + KV: 0, + PowerW: 746000, // ~1000 hp ICE + MGU-K, peak 746 kW + }, + Battery: Battery{ + VoltageV: 0, + CapacityMah: 0, + Cells: 0, + Chemistry: "li-ion MGU-K", + }, + Drive: "2WD", + TopSpeedMs: 95, // ~340 km/h + ColorHex: "#1e3a8a", // dark blue + red/yellow accents + Active: true, + TotalRaces: 0, + TotalLaps: 0, + BestLapMs: 0, + } +} + +// FerrariSF24 — Scuderia Ferrari, Leclerc / Sainz. +func FerrariSF24() Car { + return Car{ + ID: "f1-2024-ferrari-sf24", + Name: "Scuderia Ferrari SF-24", + OwnerID: "system", + Visibility: "system", + Scale: 24, + LengthMm: 238, + WidthMm: 83, + HeightMm: 40, + WeightG: 33, + Chassis: Chassis{ + Model: "SF-24 carbon monocoque", + Material: "carbon-fibre", + Printed: false, + WheelbaseMm: 150, + TrackMm: 75, + }, + Motor: Motor{ + Kind: "turbo-hybrid", + Class: "F1 PU 066/12", + KV: 0, + PowerW: 746000, + }, + Battery: Battery{ + Chemistry: "li-ion MGU-K", + }, + Drive: "2WD", + TopSpeedMs: 94, + ColorHex: "#dc2626", // rosso corsa + Active: true, + } +} + +// McLarenMCL38 — McLaren Racing, Norris / Piastri. 2024 Constructors' runner-up. +func McLarenMCL38() Car { + return Car{ + ID: "f1-2024-mclaren-mcl38", + Name: "McLaren MCL38", + OwnerID: "system", + Visibility: "system", + Scale: 24, + LengthMm: 238, + WidthMm: 83, + HeightMm: 40, + WeightG: 33, + Chassis: Chassis{ + Model: "MCL38 carbon monocoque", + Material: "carbon-fibre", + Printed: false, + WheelbaseMm: 150, + TrackMm: 75, + }, + Motor: Motor{ + Kind: "turbo-hybrid", + Class: "F1 PU Mercedes-derived", + KV: 0, + PowerW: 746000, + }, + Battery: Battery{ + Chemistry: "li-ion MGU-K", + }, + Drive: "2WD", + TopSpeedMs: 94, + ColorHex: "#ea580c", // papaya orange + Active: true, + } +} + +// MercedesW15 — Mercedes-AMG Petronas, Russell / Hamilton (last year of Hamilton at Mercedes). +func MercedesW15() Car { + return Car{ + ID: "f1-2024-mercedes-w15", + Name: "Mercedes-AMG W15", + OwnerID: "system", + Visibility: "system", + Scale: 24, + LengthMm: 238, + WidthMm: 83, + HeightMm: 40, + WeightG: 33, + Chassis: Chassis{ + Model: "W15 carbon monocoque", + Material: "carbon-fibre", + Printed: false, + WheelbaseMm: 150, + TrackMm: 75, + }, + Motor: Motor{ + Kind: "turbo-hybrid", + Class: "F1 PU M15", + KV: 0, + PowerW: 746000, + }, + Battery: Battery{ + Chemistry: "li-ion MGU-K", + }, + Drive: "2WD", + TopSpeedMs: 94, + ColorHex: "#06b6d4", // silver-petronas cyan + Active: true, + } +} + +// AstonMartinAMR24 — Aston Martin Aramco, Alonso / Stroll. +func AstonMartinAMR24() Car { + return Car{ + ID: "f1-2024-astonmartin-amr24", + Name: "Aston Martin Aramco AMR24", + OwnerID: "system", + Visibility: "system", + Scale: 24, + LengthMm: 238, + WidthMm: 83, + HeightMm: 40, + WeightG: 33, + Chassis: Chassis{ + Model: "AMR24 carbon monocoque", + Material: "carbon-fibre", + Printed: false, + WheelbaseMm: 150, + TrackMm: 75, + }, + Motor: Motor{ + Kind: "turbo-hybrid", + Class: "F1 PU Mercedes-derived", + KV: 0, + PowerW: 746000, + }, + Battery: Battery{ + Chemistry: "li-ion MGU-K", + }, + Drive: "2WD", + TopSpeedMs: 94, + ColorHex: "#16a34a", // racing green + Active: true, + } +} + +// ComputeBounds derives a bounding box from a closed loop of waypoints. +func ComputeBounds(loop []Waypoint) Bounds { + if len(loop) == 0 { + return Bounds{} + } + b := Bounds{ + MinX: loop[0].X, + MinY: loop[0].Y, + MaxX: loop[0].X, + MaxY: loop[0].Y, + } + for _, p := range loop[1:] { + if p.X < b.MinX { + b.MinX = p.X + } + if p.Y < b.MinY { + b.MinY = p.Y + } + if p.X > b.MaxX { + b.MaxX = p.X + } + if p.Y > b.MaxY { + b.MaxY = p.Y + } + } + b.LengthM = b.MaxX - b.MinX + b.WidthM = b.MaxY - b.MinY + return b +} \ No newline at end of file diff --git a/server/internal/catalog/seeddefs/seeddefs_test.go b/server/internal/catalog/seeddefs/seeddefs_test.go new file mode 100644 index 0000000..10666d5 --- /dev/null +++ b/server/internal/catalog/seeddefs/seeddefs_test.go @@ -0,0 +1,40 @@ +package seeddefs + +import "testing" + +func TestTracksFitInRoom(t *testing.T) { + const ( + roomLen = 4.5 + roomWid = 3.2 + ) + for _, tr := range DefaultTracks() { + if tr.Bounds.LengthM > roomLen { + t.Errorf("track %s: length %.3f m exceeds 4.5 m", tr.ID, tr.Bounds.LengthM) + } + if tr.Bounds.WidthM > roomWid { + t.Errorf("track %s: width %.3f m exceeds 3.2 m", tr.ID, tr.Bounds.WidthM) + } + if len(tr.Centerline) < 8 { + t.Errorf("track %s: too few waypoints (%d)", tr.ID, len(tr.Centerline)) + } + t.Logf("%-30s %5.2fm x %5.2fm %2d waypoints", tr.ID, + tr.Bounds.LengthM, tr.Bounds.WidthM, len(tr.Centerline)) + } +} + +func TestCarsValid(t *testing.T) { + for _, c := range DefaultCars() { + if c.LengthMm < 200 || c.LengthMm > 260 { + t.Errorf("car %s: length %.0f mm out of F1 1/24 range (200..260)", c.ID, c.LengthMm) + } + if c.WidthMm < 70 || c.WidthMm > 100 { + t.Errorf("car %s: width %.0f mm out of F1 1/24 range", c.ID, c.WidthMm) + } + if c.Motor.PowerW < 500000 { + t.Errorf("car %s: power %.0f W too low for F1 PU", c.ID, c.Motor.PowerW) + } + t.Logf("%-35s %dx%dx%d mm, %d W, %d m/s", c.ID, + int(c.LengthMm), int(c.WidthMm), int(c.HeightMm), + int(c.Motor.PowerW), int(c.TopSpeedMs)) + } +} \ No newline at end of file diff --git a/server/internal/catalog/seeddefs/types.go b/server/internal/catalog/seeddefs/types.go new file mode 100644 index 0000000..1c0e317 --- /dev/null +++ b/server/internal/catalog/seeddefs/types.go @@ -0,0 +1,84 @@ +package seeddefs + +// Waypoint mirrors catalog.Waypoint — duplicated here to keep the +// seeddefs package free of catalog (and thus postgres) imports. +type Waypoint struct { + X float64 + Y float64 + HeadingRad float64 + SpeedMs float64 + Curvature float64 +} + +type Bounds struct { + MinX, MinY, MaxX, MaxY float64 + LengthM float64 + WidthM float64 +} + +type Track struct { + ID string + Name string + Description string + AuthorID string + Visibility string + Scale int + LengthM float64 + WidthM float64 + LaneWidthM float64 + Surface string + Tags []string + Centerline []Waypoint + Bounds Bounds + BestLapMs int64 + BestLapHolder string +} + +type Chassis struct { + Model string + Material string + Printed bool + WheelbaseMm float64 + TrackMm float64 +} + +type Motor struct { + Kind string + Class string + KV int + PowerW float64 +} + +type Battery struct { + VoltageV float64 + CapacityMah int + Cells int + Chemistry string +} + +type Car struct { + ID string + Name string + OwnerID string + Visibility string + Scale int + + LengthMm float64 + WidthMm float64 + HeightMm float64 + WeightG float64 + + Chassis Chassis + Motor Motor + Battery Battery + Drive string + + TopSpeedMs float64 + ColorHex string + Active bool + + TotalDistanceM float64 + TotalRaces int + TotalLaps int + BestLapMs int64 +} \ No newline at end of file diff --git a/server/internal/catalog/store.go b/server/internal/catalog/store.go new file mode 100644 index 0000000..3c861fc --- /dev/null +++ b/server/internal/catalog/store.go @@ -0,0 +1,30 @@ +package catalog + +import ( + "context" +) + +// Store is the persistence layer used by Service. The runtime uses +// postgres.PgStore; tests use memoryStore (defined in catalog_test.go). +type Store interface { + // Load populates the in-memory cache at startup. Returns the full + // snapshot: tracks (with waypoints and tags) and cars. + Load(ctx context.Context) ([]TrackMeta, []CarMeta, error) + + // UpsertTrack inserts or updates a track along with its waypoints and + // tags in a single transaction. + UpsertTrack(ctx context.Context, t TrackMeta) error + + // DeleteTrack removes a track and all its waypoints / tags. + DeleteTrack(ctx context.Context, id string) error + + // UpsertCar inserts or updates a car. + UpsertCar(ctx context.Context, c CarMeta) error + + // DeleteCar removes a car by id. + DeleteCar(ctx context.Context, id string) error + + // UpdateCarStats applies a partial mutation of read-mostly stats + // fields on a car. Used by the race engine hooks. + UpdateCarStats(ctx context.Context, id string, fn func(*CarMeta)) error +} \ No newline at end of file diff --git a/server/internal/catalog/store_memory.go b/server/internal/catalog/store_memory.go new file mode 100644 index 0000000..4e2155e --- /dev/null +++ b/server/internal/catalog/store_memory.go @@ -0,0 +1,75 @@ +package catalog + +import ( + "context" + "sync" +) + +// memoryStore is an in-process implementation of Store for tests and +// for early PoC runs without a database. It is goroutine-safe. +type memoryStore struct { + mu sync.Mutex + tracks map[TrackID]TrackMeta + cars map[CarID]CarMeta +} + +func newMemoryStore() *memoryStore { + return &memoryStore{ + tracks: make(map[TrackID]TrackMeta), + cars: make(map[CarID]CarMeta), + } +} + +func (m *memoryStore) Load(_ context.Context) ([]TrackMeta, []CarMeta, error) { + m.mu.Lock() + defer m.mu.Unlock() + tracks := make([]TrackMeta, 0, len(m.tracks)) + for _, t := range m.tracks { + tracks = append(tracks, t) + } + cars := make([]CarMeta, 0, len(m.cars)) + for _, c := range m.cars { + cars = append(cars, c) + } + return tracks, cars, nil +} + +func (m *memoryStore) UpsertTrack(_ context.Context, t TrackMeta) error { + m.mu.Lock() + defer m.mu.Unlock() + m.tracks[t.ID] = t + return nil +} + +func (m *memoryStore) DeleteTrack(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.tracks, id) + return nil +} + +func (m *memoryStore) UpsertCar(_ context.Context, c CarMeta) error { + m.mu.Lock() + defer m.mu.Unlock() + m.cars[c.ID] = c + return nil +} + +func (m *memoryStore) DeleteCar(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.cars, id) + return nil +} + +func (m *memoryStore) UpdateCarStats(_ context.Context, id string, fn func(*CarMeta)) error { + m.mu.Lock() + defer m.mu.Unlock() + c, ok := m.cars[id] + if !ok { + return ErrNotFound + } + fn(&c) + m.cars[id] = c + return nil +} \ No newline at end of file diff --git a/server/internal/catalog/types.go b/server/internal/catalog/types.go new file mode 100644 index 0000000..fbabe4d --- /dev/null +++ b/server/internal/catalog/types.go @@ -0,0 +1,546 @@ +// 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). +package catalog + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "time" +) + +// Visibility controls who can see / pick a track or car. +type Visibility string + +const ( + VisibilitySystem Visibility = "system" // built-in, immutable + VisibilityPublic Visibility = "public" // visible to everyone + VisibilityPrivate Visibility = "private" // owner-only +) + +// Surface is the type of floor the track is laid on. +type Surface string + +const ( + SurfaceCarpet Surface = "carpet" + SurfaceTile Surface = "tile" + SurfaceWood Surface = "wood" + SurfacePaper Surface = "paper" + SurfaceMixed Surface = "mixed" +) + +// Drivetrain of a car. +type Drivetrain string + +const ( + Drivetrain2WD Drivetrain = "2WD" + Drivetrain4WD Drivetrain = "4WD" +) + +// Waypoint is one sample of the racing line of a track. +// The centerline is a closed loop: waypoint N wraps back to waypoint 0. +type Waypoint struct { + X float64 `json:"x"` // metres from track origin (lower-left) + Y float64 `json:"y"` // metres + HeadingRad float64 `json:"heading_rad"` // tangent angle, radians + SpeedMs float64 `json:"speed_ms"` // suggested cornering speed (m/s) + Curvature float64 `json:"curvature"` // 1/radius; 0 = straight +} + +// Bounds are the bounding box of a track (metres, lower-left origin). +type Bounds struct { + MinX float64 `json:"min_x"` + MinY float64 `json:"min_y"` + MaxX float64 `json:"max_x"` + MaxY float64 `json:"max_y"` + LengthM float64 `json:"length_m"` // longest axis + WidthM float64 `json:"width_m"` // shortest axis +} + +// TrackMeta describes one track. +type TrackMeta struct { + ID string `json:"id"` // kebab-case slug + Name string `json:"name"` + Description string `json:"description,omitempty"` + AuthorID string `json:"author_id"` // driver id or "system" + Visibility Visibility `json:"visibility"` + Scale int `json:"scale"` // nominal, e.g. 27 for 1/27 + LengthM float64 `json:"length_m"` // physical length (bounding box long axis) + WidthM float64 `json:"width_m"` // physical width + LaneWidthM float64 `json:"lane_width_m"` // recommended lane width + Bounds Bounds `json:"bounds"` // computed bounding box + Surface Surface `json:"surface"` + Tags []string `json:"tags,omitempty"` + Centerline []Waypoint `json:"centerline"` // racing line + BestLapMs int64 `json:"best_lap_ms"` // 0 if no record + BestLapHolder string `json:"best_lap_holder,omitempty"` + CreatedMs int64 `json:"created_ms"` + UpdatedMs int64 `json:"updated_ms"` +} + +// MotorSpec describes the electric motor. +type MotorSpec struct { + Kind string `json:"kind"` // brushed | brushless + Class string `json:"class"` // "130", "180", "280", "030-size" + KV int `json:"kv"` // brushless RPM/V; 0 for brushed + PowerW float64 `json:"power_w"` // peak watts +} + +// BatterySpec describes the battery pack. +type BatterySpec struct { + VoltageV float64 `json:"voltage_v"` // nominal voltage + CapacityMah int `json:"capacity_mah"` + Cells int `json:"cells"` // 1S..6S + Chemistry string `json:"chemistry"` // "li-po" | "li-ion" | "nimh" +} + +// ChassisSpec describes the chassis / body. +type ChassisSpec struct { + Model string `json:"model"` // free-form + Material string `json:"material"` // "pla" | "petg" | "abs" | "carbon" + Printed bool `json:"printed"` // 3D-printed body / chassis? + WheelbaseMm float64 `json:"wheelbase_mm"` + TrackMm float64 `json:"track_mm"` // front/rear track (avg) +} + +// CarMeta describes one car (chassis + powertrain + visual + stats). +type CarMeta struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerID string `json:"owner_id"` // driver id or "system" + Visibility Visibility `json:"visibility"` + Scale int `json:"scale"` // 27 for 1/27 + + // Physical envelope (millimetres). + LengthMm float64 `json:"length_mm"` + WidthMm float64 `json:"width_mm"` + HeightMm float64 `json:"height_mm"` + WeightG float64 `json:"weight_g"` + + // Specs. + Chassis ChassisSpec `json:"chassis"` + Motor MotorSpec `json:"motor"` + Battery BatterySpec `json:"battery"` + Drive Drivetrain `json:"drive"` + + // Performance envelope. + TopSpeedMs float64 `json:"top_speed_ms"` + + // Visual. + ColorHex string `json:"color_hex"` + AvatarURL string `json:"avatar_url,omitempty"` + + // Lifecycle. + Active bool `json:"active"` + + // Stats (read-mostly; mutated by race engine via SetStats). + TotalDistanceM float64 `json:"total_distance_m"` + TotalRaces int `json:"total_races"` + TotalLaps int `json:"total_laps"` + BestLapMs int64 `json:"best_lap_ms"` + + CreatedMs int64 `json:"created_ms"` + UpdatedMs int64 `json:"updated_ms"` +} + +// Snapshot is the immutable view broadcast to clients. +type Snapshot struct { + GeneratedMs int64 `json:"generated_ms"` + Version uint64 `json:"version"` + Tracks []TrackMeta `json:"tracks"` + Cars []CarMeta `json:"cars"` +} + +// Stats are lightweight counters (for /api/catalog summary). +type Stats struct { + TracksTotal int `json:"tracks_total"` + TracksSystem int `json:"tracks_system"` + TracksPublic int `json:"tracks_public"` + TracksPrivate int `json:"tracks_private"` + CarsTotal int `json:"cars_total"` + CarsSystem int `json:"cars_system"` + CarsPublic int `json:"cars_public"` + CarsPrivate int `json:"cars_private"` + CarsActive int `json:"cars_active"` +} + +// TrackID is a type alias to avoid stringly-typed APIs. +type TrackID = string + +// CarID is a type alias to avoid stringly-typed APIs. +type CarID = string + +// AuthorSystem is the author ID used for built-in resources. +const AuthorSystem = "system" + +// EventKind tags the kind of change in an Event. +type EventKind string + +const ( + EventSnapshot EventKind = "snapshot" + EventTrackUpsert EventKind = "track_upsert" + EventTrackDelete EventKind = "track_delete" + EventCarUpsert EventKind = "car_upsert" + EventCarDelete EventKind = "car_delete" +) + +// Event is the internal broadcast (not exported on the wire). +type Event struct { + Kind EventKind + TrackID TrackID + CarID CarID + Snapshot Snapshot +} + +// Service owns the catalog cache. Mutations are persisted via Store and +// then reflected in the in-memory cache. Safe for concurrent use. +// +// Design note: a single Service holds both tracks and cars so the +// version counter is shared — clients can use it as a single ordering +// signal for the catalog feed. +type Service struct { + store Store + + mu sync.RWMutex + tracks map[TrackID]*TrackMeta + cars map[CarID]*CarMeta + version atomic.Uint64 + + subsMu sync.RWMutex + subs map[chan Event]struct{} + + stopCh chan struct{} + doneCh chan struct{} +} + +// NewService creates a Service backed by the given Store and loads +// existing tracks / cars from it. The Store may be nil (then Load is +// skipped and the cache is empty) — useful for early unit tests. +func NewService(ctx context.Context, store Store) (*Service, error) { + s := &Service{ + store: store, + tracks: make(map[TrackID]*TrackMeta), + cars: make(map[CarID]*CarMeta), + subs: make(map[chan Event]struct{}), + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } + if store != nil { + tracks, cars, err := store.Load(ctx) + if err != nil { + return nil, err + } + for i := range tracks { + t := tracks[i] + s.normalizeTrack(&t) + s.tracks[t.ID] = &t + } + for i := range cars { + c := cars[i] + s.normalizeCar(&c) + s.cars[c.ID] = &c + } + } + return s, nil +} + +// normalizeTrack fills computed fields (Bounds, CreatedMs, etc.) and +// validates minimal invariants. +func (s *Service) normalizeTrack(t *TrackMeta) { + if t.ID == "" { + return + } + if t.Scale == 0 { + t.Scale = 27 + } + if t.Surface == "" { + t.Surface = SurfaceCarpet + } + if t.Visibility == "" { + t.Visibility = VisibilitySystem + } + if t.LaneWidthM == 0 { + t.LaneWidthM = 0.20 + } + if t.AuthorID == "" { + t.AuthorID = AuthorSystem + } + if len(t.Centerline) == 0 { + t.Centerline = defaultCenterline(t.LengthM, t.WidthM) + } + // Recompute bounds from centerline if missing. + if t.Bounds.MaxX == 0 || t.Bounds.MaxY == 0 { + t.Bounds = computeBounds(t.Centerline) + t.LengthM = t.Bounds.LengthM + t.WidthM = t.Bounds.WidthM + } + now := time.Now().UnixMilli() + if t.CreatedMs == 0 { + t.CreatedMs = now + } + if t.UpdatedMs == 0 { + t.UpdatedMs = now + } +} + +// normalizeCar fills defaults and timestamps. +func (s *Service) normalizeCar(c *CarMeta) { + if c.ID == "" { + return + } + if c.Scale == 0 { + c.Scale = 27 + } + if c.Visibility == "" { + c.Visibility = VisibilitySystem + } + if c.Drive == "" { + c.Drive = Drivetrain2WD + } + if c.OwnerID == "" { + c.OwnerID = AuthorSystem + } + if c.ColorHex == "" { + c.ColorHex = "#ff6b1a" + } + if c.Battery.Chemistry == "" { + c.Battery.Chemistry = "li-po" + } + now := time.Now().UnixMilli() + if c.CreatedMs == 0 { + c.CreatedMs = now + } + if c.UpdatedMs == 0 { + c.UpdatedMs = now + } +} + +// Subscribe returns a channel that receives catalog events. The caller +// MUST invoke the returned cancel func to release resources. +func (s *Service) Subscribe() (<-chan Event, func()) { + ch := make(chan Event, 16) + s.subsMu.Lock() + s.subs[ch] = struct{}{} + s.subsMu.Unlock() + + // Push an initial snapshot so subscribers can render immediately. + ch <- Event{Kind: EventSnapshot, Snapshot: s.snapshot()} + + return ch, func() { + s.subsMu.Lock() + if _, ok := s.subs[ch]; ok { + delete(s.subs, ch) + close(ch) + } + s.subsMu.Unlock() + } +} + +// Stop terminates the service. Safe to call multiple times. +func (s *Service) Stop() { + select { + case <-s.stopCh: + return + default: + close(s.stopCh) + s.subsMu.Lock() + for ch := range s.subs { + delete(s.subs, ch) + close(ch) + } + s.subsMu.Unlock() + close(s.doneCh) + } +} + +// Done blocks until Stop() is called. +func (s *Service) Done() <-chan struct{} { return s.doneCh } + +// Snapshot returns an immutable copy of the catalog state. +func (s *Service) Snapshot() Snapshot { return s.snapshot() } + +func (s *Service) snapshot() Snapshot { + s.mu.RLock() + defer s.mu.RUnlock() + out := Snapshot{ + GeneratedMs: time.Now().UnixMilli(), + Version: s.version.Load(), + Tracks: make([]TrackMeta, 0, len(s.tracks)), + Cars: make([]CarMeta, 0, len(s.cars)), + } + for _, t := range s.tracks { + out.Tracks = append(out.Tracks, *t) + } + for _, c := range s.cars { + out.Cars = append(out.Cars, *c) + } + return out +} + +// Stats returns counters grouped by visibility. +func (s *Service) Stats() Stats { + s.mu.RLock() + defer s.mu.RUnlock() + out := Stats{} + for _, t := range s.tracks { + out.TracksTotal++ + switch t.Visibility { + case VisibilitySystem: + out.TracksSystem++ + case VisibilityPublic: + out.TracksPublic++ + case VisibilityPrivate: + out.TracksPrivate++ + } + } + for _, c := range s.cars { + out.CarsTotal++ + switch c.Visibility { + case VisibilitySystem: + out.CarsSystem++ + case VisibilityPublic: + out.CarsPublic++ + case VisibilityPrivate: + out.CarsPrivate++ + } + if c.Active { + out.CarsActive++ + } + } + return out +} + +// ListTracks returns a copy of all tracks. +func (s *Service) ListTracks() []TrackMeta { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]TrackMeta, 0, len(s.tracks)) + for _, t := range s.tracks { + out = append(out, *t) + } + return out +} + +// ListCars returns a copy of all cars. +func (s *Service) ListCars() []CarMeta { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]CarMeta, 0, len(s.cars)) + for _, c := range s.cars { + out = append(out, *c) + } + return out +} + +// GetTrack returns a track by ID (by value, safe to mutate). +func (s *Service) GetTrack(id string) (TrackMeta, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + t, ok := s.tracks[id] + if !ok { + return TrackMeta{}, false + } + return *t, true +} + +// GetCar returns a car by ID (by value, safe to mutate). +func (s *Service) GetCar(id string) (CarMeta, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + c, ok := s.cars[id] + if !ok { + return CarMeta{}, false + } + return *c, true +} + +// persistUpsertTrack writes a track through the Store and updates the +// cache. Used by all track write paths. +func (s *Service) persistUpsertTrack(ctx context.Context, t TrackMeta) error { + if s.store == nil { + return errors.New("catalog: store not configured") + } + s.normalizeTrack(&t) + if err := s.store.UpsertTrack(ctx, t); err != nil { + return err + } + s.mu.Lock() + s.tracks[t.ID] = &t + s.mu.Unlock() + s.bump(Event{Kind: EventTrackUpsert, TrackID: t.ID}) + return nil +} + +func (s *Service) persistDeleteTrack(ctx context.Context, id string) error { + if s.store == nil { + return errors.New("catalog: store not configured") + } + if err := s.store.DeleteTrack(ctx, id); err != nil { + return err + } + s.mu.Lock() + delete(s.tracks, id) + s.mu.Unlock() + s.bump(Event{Kind: EventTrackDelete, TrackID: id}) + return nil +} + +func (s *Service) persistUpsertCar(ctx context.Context, c CarMeta) error { + if s.store == nil { + return errors.New("catalog: store not configured") + } + s.normalizeCar(&c) + if err := s.store.UpsertCar(ctx, c); err != nil { + return err + } + s.mu.Lock() + s.cars[c.ID] = &c + s.mu.Unlock() + s.bump(Event{Kind: EventCarUpsert, CarID: c.ID}) + return nil +} + +func (s *Service) persistDeleteCar(ctx context.Context, id string) error { + if s.store == nil { + return errors.New("catalog: store not configured") + } + if err := s.store.DeleteCar(ctx, id); err != nil { + return err + } + s.mu.Lock() + delete(s.cars, id) + s.mu.Unlock() + s.bump(Event{Kind: EventCarDelete, CarID: id}) + return nil +} + +// bump increments the version and broadcasts. Caller MUST NOT hold s.mu. +func (s *Service) bump(ev Event) { + s.version.Add(1) + ev.Snapshot = s.snapshot() + s.subsMu.RLock() + defer s.subsMu.RUnlock() + for ch := range s.subs { + select { + case ch <- ev: + default: + // Slow subscriber — they'll re-sync via the next snapshot. + } + } +} \ No newline at end of file diff --git a/server/internal/clans/service.go b/server/internal/clans/service.go new file mode 100644 index 0000000..3a16e2d --- /dev/null +++ b/server/internal/clans/service.go @@ -0,0 +1,104 @@ +package clans + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgconn" +) + +// Service composes validation on top of PgStore. +type Service struct { + pg *PgStore + now func() time.Time +} + +// NewService wires a service. +func NewService(pg *PgStore) *Service { + return &Service{pg: pg, now: time.Now} +} + +// CreateInput is what the HTTP handler hands to the service. +type CreateInput struct { + ID string + Tag string + Name string + AvatarURL string +} + +// Create validates and inserts a clan. +func (s *Service) Create(ctx context.Context, in CreateInput) (Clan, error) { + tag := strings.ToUpper(strings.TrimSpace(in.Tag)) + if !IsValidTag(tag) { + return Clan{}, fmt.Errorf("%w: tag must be 3 uppercase ASCII letters", ErrInvalidInput) + } + name := strings.TrimSpace(in.Name) + if name == "" { + return Clan{}, fmt.Errorf("%w: name required", ErrInvalidInput) + } + c := Clan{ + ID: in.ID, + Tag: tag, + Name: name, + AvatarURL: in.AvatarURL, + } + if c.ID == "" { + c.ID = fmt.Sprintf("clan-%d-%d", s.now().UnixMilli(), time.Now().UnixNano()%1000) + } + if err := s.pg.Create(ctx, c); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return Clan{}, fmt.Errorf("%w: %s", ErrAlreadyExists, tag) + } + return Clan{}, err + } + return s.pg.Get(ctx, c.ID) +} + +// Get loads a clan by id. +func (s *Service) Get(ctx context.Context, id string) (Clan, error) { + return s.pg.Get(ctx, id) +} + +// GetByTag loads a clan by tag. +func (s *Service) GetByTag(ctx context.Context, tag string) (Clan, error) { + tag = strings.ToUpper(strings.TrimSpace(tag)) + return s.pg.GetByTag(ctx, tag) +} + +// List returns clans with limit/offset. +func (s *Service) List(ctx context.Context, limit, offset int) ([]Clan, error) { + return s.pg.List(ctx, limit, offset) +} + +// UpdateInput is the patch payload. +type UpdateInput struct { + Name string + AvatarURL string +} + +// Update applies a partial patch. +func (s *Service) Update(ctx context.Context, id string, in UpdateInput) (Clan, error) { + cur, err := s.pg.Get(ctx, id) + if err != nil { + return Clan{}, err + } + if in.Name != "" { + cur.Name = in.Name + } + if in.AvatarURL != "" { + cur.AvatarURL = in.AvatarURL + } + if err := s.pg.Update(ctx, cur); err != nil { + return Clan{}, err + } + return s.pg.Get(ctx, id) +} + +// Delete removes a clan. +func (s *Service) Delete(ctx context.Context, id string) error { + return s.pg.Delete(ctx, id) +} diff --git a/server/internal/clans/store.go b/server/internal/clans/store.go new file mode 100644 index 0000000..efca8c2 --- /dev/null +++ b/server/internal/clans/store.go @@ -0,0 +1,171 @@ +// 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). +package clans + +import ( + "context" + "errors" + "fmt" + "regexp" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Errors returned by the service. +var ( + ErrNotFound = errors.New("clan not found") + ErrInvalidInput = errors.New("invalid input") + ErrAlreadyExists = errors.New("clan already exists") +) + +// tagRE enforces exactly 3 uppercase ASCII letters. +var tagRE = regexp.MustCompile(`^[A-Z]{3}$`) + +// IsValidTag reports whether s is a valid clan/driver tag. +func IsValidTag(s string) bool { return tagRE.MatchString(s) } + +// Clan is the canonical in-memory representation. +type Clan struct { + ID string + Tag string + Name string + AvatarURL string + CreatedMs int64 + UpdatedMs int64 +} + +// PgStore is the Postgres-backed half of the package. +type PgStore struct { + pool *pgxpool.Pool +} + +// NewPgStore wraps an open pgxpool. +func NewPgStore(pool *pgxpool.Pool) *PgStore { + return &PgStore{pool: pool} +} + +// Exec exposes raw SQL for the seeder. +func (s *PgStore) Exec(ctx context.Context, sql string) (int64, error) { + tag, err := s.pool.Exec(ctx, sql) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +// Create inserts a clan. +func (s *PgStore) Create(ctx context.Context, c Clan) error { + now := time.Now().UnixMilli() + if c.CreatedMs == 0 { + c.CreatedMs = now + } + c.UpdatedMs = now + _, err := s.pool.Exec(ctx, ` + INSERT INTO clans (id, tag, name, avatar_url, created_ms, updated_ms) + VALUES ($1, $2, $3, $4, $5, $6)`, + c.ID, c.Tag, c.Name, c.AvatarURL, c.CreatedMs, c.UpdatedMs) + if err != nil { + return fmt.Errorf("insert clan: %w", err) + } + return nil +} + +// Get loads a clan by id. +func (s *PgStore) Get(ctx context.Context, id string) (Clan, error) { + row := s.pool.QueryRow(ctx, + `SELECT id, tag, name, avatar_url, created_ms, updated_ms + FROM clans WHERE id = $1`, id) + var c Clan + if err := row.Scan(&c.ID, &c.Tag, &c.Name, &c.AvatarURL, &c.CreatedMs, &c.UpdatedMs); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Clan{}, ErrNotFound + } + return Clan{}, err + } + return c, nil +} + +// GetByTag loads a clan by its 3-letter tag. +func (s *PgStore) GetByTag(ctx context.Context, tag string) (Clan, error) { + row := s.pool.QueryRow(ctx, + `SELECT id, tag, name, avatar_url, created_ms, updated_ms + FROM clans WHERE tag = $1`, tag) + var c Clan + if err := row.Scan(&c.ID, &c.Tag, &c.Name, &c.AvatarURL, &c.CreatedMs, &c.UpdatedMs); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Clan{}, ErrNotFound + } + return Clan{}, err + } + return c, nil +} + +// List returns all clans, ordered by tag. +func (s *PgStore) List(ctx context.Context, limit, offset int) ([]Clan, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + rows, err := s.pool.Query(ctx, + `SELECT id, tag, name, avatar_url, created_ms, updated_ms + FROM clans ORDER BY tag ASC LIMIT $1 OFFSET $2`, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]Clan, 0) + for rows.Next() { + var c Clan + if err := rows.Scan(&c.ID, &c.Tag, &c.Name, &c.AvatarURL, &c.CreatedMs, &c.UpdatedMs); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// Delete removes a clan by id. +func (s *PgStore) Delete(ctx context.Context, id string) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM clans WHERE id = $1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// Update applies a partial patch. +func (s *PgStore) Update(ctx context.Context, c Clan) error { + c.UpdatedMs = time.Now().UnixMilli() + tag, err := s.pool.Exec(ctx, ` + UPDATE clans + SET name = $2, avatar_url = $3, updated_ms = $4 + WHERE id = $1`, + c.ID, c.Name, c.AvatarURL, c.UpdatedMs) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// Count returns the total number of clans. +func (s *PgStore) Count(ctx context.Context) (int, error) { + var n int + row := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM clans`) + if err := row.Scan(&n); err != nil { + return 0, err + } + return n, nil +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 0000000..6183452 --- /dev/null +++ b/server/internal/config/config.go @@ -0,0 +1,117 @@ +// 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. +package config + +import ( + "fmt" + "os" + "strconv" + "time" + + "github.com/joho/godotenv" +) + +// Config holds all runtime parameters. +type Config struct { + HTTPAddr string // Address to listen on, e.g. ":8080" + TickRate int // Race tick rate (Hz) + TickInterval time.Duration // Derived from TickRate + LogLevel string // "debug" | "info" | "warn" | "error" + MaxClients int // Maximum concurrent WS clients + SnapshotRate int // Snapshot fan-out rate (Hz) + DevMode bool // If true, allow multiple clients without auth + + DatabaseURL string // Postgres connection string; required at runtime +} + +// Load reads configuration from environment variables, applying defaults. +// +// A .env file in the current working directory (or any path listed in +// X0GP_DOTENV) is loaded before reading the environment. Missing .env +// is not an error — production deployments set vars directly. +func Load() (*Config, error) { + loadDotEnv() + + c := &Config{ + HTTPAddr: getEnv("X0GP_HTTP_ADDR", ":8080"), + TickRate: getEnvInt("X0GP_TICK_RATE", 60), + LogLevel: getEnv("X0GP_LOG_LEVEL", "info"), + MaxClients: getEnvInt("X0GP_MAX_CLIENTS", 100), + SnapshotRate: getEnvInt("X0GP_SNAPSHOT_RATE", 30), + DevMode: getEnvBool("X0GP_DEV_MODE", true), + DatabaseURL: getEnv("DATABASE_URL", ""), + } + + if c.TickRate <= 0 || c.TickRate > 240 { + return nil, fmt.Errorf("X0GP_TICK_RATE must be in (0, 240], got %d", c.TickRate) + } + c.TickInterval = time.Second / time.Duration(c.TickRate) + + if c.SnapshotRate <= 0 || c.SnapshotRate > c.TickRate { + return nil, fmt.Errorf("X0GP_SNAPSHOT_RATE must be in (0, %d], got %d", + c.TickRate, c.SnapshotRate) + } + + return c, nil +} + +func getEnv(key, fallback string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + return fallback +} + +// loadDotEnv merges a .env file into the process environment. Files +// that do not exist are silently ignored. Paths can be overridden with +// X0GP_DOTENV (colon-separated). Existing process env vars take +// precedence over .env values so deploys can override without +// editing the file (godotenv.Load, not Overload). +func loadDotEnv() { + if paths := os.Getenv("X0GP_DOTENV"); paths != "" { + for _, p := range splitPaths(paths) { + _ = godotenv.Load(p) + } + return + } + _ = godotenv.Load(".env") +} + +func splitPaths(s string) []string { + out := make([]string, 0, 4) + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == ':' || s[i] == ';' { + if i > start { + out = append(out, s[start:i]) + } + start = i + 1 + } + } + if start < len(s) { + out = append(out, s[start:]) + } + return out +} + +func getEnvInt(key string, fallback int) int { + if v, ok := os.LookupEnv(key); ok { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return fallback +} + +func getEnvBool(key string, fallback bool) bool { + if v, ok := os.LookupEnv(key); ok { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return fallback +} diff --git a/server/internal/control/race.go b/server/internal/control/race.go new file mode 100644 index 0000000..ba65d5a --- /dev/null +++ b/server/internal/control/race.go @@ -0,0 +1,296 @@ +// 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. +package control + +import ( + "context" + "fmt" + "math" + "sync" + "sync/atomic" + "time" + + "github.com/x0gp/server/internal/transport" +) + +// RacePhase describes the lifecycle of a race. +type RacePhase int + +const ( + PhaseLobby RacePhase = 0 + PhaseCountdown RacePhase = 1 + PhaseRacing RacePhase = 2 + PhaseFinished RacePhase = 3 +) + +func (p RacePhase) String() string { + switch p { + case PhaseLobby: + return "lobby" + case PhaseCountdown: + return "countdown" + case PhaseRacing: + return "racing" + case PhaseFinished: + return "finished" + default: + return "unknown" + } +} + +// Car is the authoritative state of a single car. +type Car struct { + ID string + DriverID string // session id + DriverName string + X, Y float64 + Heading float64 // radians + Speed float64 // m/s + Lap int + Sector int + LastLapMs int64 + BestLapMs int64 + DNF bool + + // Last applied input (echoed in snapshot for client reconciliation). + AppliedSteering float64 + AppliedThrottle float64 + AppliedBrake float64 + + mu sync.Mutex // guards input application +} + +// ApplyInput applies a control input with ramp limits (sanity-check). +func (c *Car) ApplyInput(in transport.InputState, dtSec float64) { + c.mu.Lock() + defer c.mu.Unlock() + + // Sanity clamps. + s := clamp(in.Steering, -1, 1) + t := clamp(in.Throttle, 0, 1) + b := clamp(in.Brake, 0, 1) + + // Throttle / brake -> longitudinal acceleration. + const maxAccel = 4.0 // m/s^2 + const maxBrake = 8.0 // m/s^2 + const maxSpeed = 6.0 // m/s (1/27 scale, ~21 km/h) + const dragK = 0.5 // linear drag coefficient + + accel := maxAccel*t - maxBrake*b - dragK*c.Speed + c.Speed += accel * dtSec + if c.Speed < 0 { + c.Speed = 0 + } + if c.Speed > maxSpeed { + c.Speed = maxSpeed + } + + // Steering -> yaw rate, proportional to speed (no turn at standstill). + const maxYawRate = 3.5 // rad/s + yawRate := maxYawRate * s * (0.3 + 0.7*math.Min(c.Speed/maxSpeed, 1)) + c.Heading += yawRate * dtSec + + // Position integration. + c.X += c.Speed * math.Cos(c.Heading) * dtSec + c.Y += c.Speed * math.Sin(c.Heading) * dtSec + + c.AppliedSteering = s + c.AppliedThrottle = t + c.AppliedBrake = b +} + +// Snapshot returns a copy of car state for the wire snapshot. +func (c *Car) Snapshot() transport.CarInfo { + c.mu.Lock() + defer c.mu.Unlock() + info := transport.CarInfo{ + ID: c.ID, + DriverName: c.DriverName, + X: c.X, + Y: c.Y, + Heading: c.Heading, + Speed: c.Speed, + Lap: c.Lap, + Sector: c.Sector, + LastLapMs: c.LastLapMs, + BestLapMs: c.BestLapMs, + DNF: c.DNF, + } + info.InputApplied.Steering = c.AppliedSteering + info.InputApplied.Throttle = c.AppliedThrottle + info.InputApplied.Brake = c.AppliedBrake + return info +} + +// Engine is the authoritative race engine. +// +// Not safe to copy; pass by pointer. +type Engine struct { + mu sync.RWMutex + phase atomic.Int32 // RacePhase + tick atomic.Uint64 + cars map[string]*Car // keyed by Car.ID + byDriver map[string]*Car // keyed by DriverID + tickRate int + dtSec float64 + + // Listeners for snapshot fan-out. + snapCh chan transport.RaceSnapshot + + // Lifecycle. + stopCh chan struct{} +} + +// NewEngine creates a race engine. tickRateHz is the authoritative tick rate. +func NewEngine(tickRateHz int) *Engine { + return &Engine{ + phase: atomic.Int32{}, + cars: make(map[string]*Car), + byDriver: make(map[string]*Car), + tickRate: tickRateHz, + dtSec: 1.0 / float64(tickRateHz), + snapCh: make(chan transport.RaceSnapshot, 64), + stopCh: make(chan struct{}), + } +} + +// Snapshots returns the channel of authoritative race snapshots. +func (e *Engine) Snapshots() <-chan transport.RaceSnapshot { return e.snapCh } + +// Phase returns the current race phase. +func (e *Engine) Phase() RacePhase { return RacePhase(e.phase.Load()) } + +// SetPhase transitions the race phase. +func (e *Engine) SetPhase(p RacePhase) { e.phase.Store(int32(p)) } + +// AddCar registers a car and returns it. Returns an error if the driver +// already has a car or the max car count is reached. +func (e *Engine) AddCar(driverID, driverName string, slot int) (*Car, error) { + e.mu.Lock() + defer e.mu.Unlock() + + if _, exists := e.byDriver[driverID]; exists { + return nil, fmt.Errorf("driver %s already has a car", driverID) + } + if len(e.cars) >= 4 { + return nil, fmt.Errorf("race is full (max 4 cars for PoC)") + } + + id := fmt.Sprintf("car-%d", slot) + car := &Car{ + ID: id, + DriverID: driverID, + DriverName: driverName, + // Spread cars along start grid (x = 0, y = slot * 0.4 m). + Y: float64(slot) * 0.4, + } + e.cars[id] = car + e.byDriver[driverID] = car + return car, nil +} + +// RemoveCar removes a car by driver id. +func (e *Engine) RemoveCar(driverID string) { + e.mu.Lock() + defer e.mu.Unlock() + car, ok := e.byDriver[driverID] + if !ok { + return + } + delete(e.byDriver, driverID) + delete(e.cars, car.ID) +} + +// GetCarByDriver returns the car for a driver (or nil). +func (e *Engine) GetCarByDriver(driverID string) *Car { + e.mu.RLock() + defer e.mu.RUnlock() + return e.byDriver[driverID] +} + +// Run drives the tick loop. Cancelled by ctx or Stop(). +func (e *Engine) Run(ctx context.Context) { + period := time.Duration(float64(time.Second) / float64(e.tickRate)) + t := time.NewTicker(period) + defer t.Stop() + + // Snapshot cadence: 30 Hz for PoC (half tick rate is usually enough). + const snapshotEveryTicks = 2 + var sinceSnap int + + for { + select { + case <-ctx.Done(): + return + case <-e.stopCh: + return + case <-t.C: + e.advance() + sinceSnap++ + if sinceSnap >= snapshotEveryTicks { + sinceSnap = 0 + e.publishSnapshot() + } + } + } +} + +// Stop terminates the tick loop. +func (e *Engine) Stop() { close(e.stopCh) } + +// advance integrates one tick of physics (no-op if no cars). +func (e *Engine) advance() { + e.tick.Add(1) + e.mu.RLock() + defer e.mu.RUnlock() + for _, car := range e.cars { + if car.DNF { + continue + } + // PoC: keep applying last input without changes. + // In real life, the input pipeline feeds this. + in := transport.InputState{ + Steering: car.AppliedSteering, + Throttle: car.AppliedThrottle, + Brake: car.AppliedBrake, + } + car.ApplyInput(in, e.dtSec) + } +} + +// publishSnapshot sends a snapshot to subscribers (non-blocking). +func (e *Engine) publishSnapshot() { + e.mu.RLock() + cars := make([]transport.CarInfo, 0, len(e.cars)) + for _, c := range e.cars { + cars = append(cars, c.Snapshot()) + } + e.mu.RUnlock() + + snap := transport.RaceSnapshot{ + Tick: e.tick.Load(), + TSMs: time.Now().UnixMilli(), + Elapsed: float64(e.tick.Load()) * e.dtSec, + Cars: cars, + } + + select { + case e.snapCh <- snap: + default: + // Drop if subscriber is slow; log in prod. + } +} + +func clamp(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/server/internal/drivers/service.go b/server/internal/drivers/service.go new file mode 100644 index 0000000..e7a9449 --- /dev/null +++ b/server/internal/drivers/service.go @@ -0,0 +1,110 @@ +package drivers + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgconn" +) + +// Service composes validation on top of PgStore. +type Service struct { + pg *PgStore + now func() time.Time +} + +// NewService wires a service. +func NewService(pg *PgStore) *Service { + return &Service{pg: pg, now: time.Now} +} + +// CreateInput is what the HTTP handler hands to the service. +type CreateInput struct { + ID string + Nickname string + Name string + AvatarURL string + ClanID string +} + +// Create validates and inserts a driver. +func (s *Service) Create(ctx context.Context, in CreateInput) (Driver, error) { + nick := strings.ToUpper(strings.TrimSpace(in.Nickname)) + if !IsValidNickname(nick) { + return Driver{}, fmt.Errorf("%w: nickname must be 3 uppercase ASCII letters", ErrInvalidInput) + } + name := strings.TrimSpace(in.Name) + if name == "" { + return Driver{}, fmt.Errorf("%w: name required", ErrInvalidInput) + } + d := Driver{ + ID: in.ID, + Nickname: nick, + Name: name, + AvatarURL: in.AvatarURL, + ClanID: in.ClanID, + } + if d.ID == "" { + d.ID = fmt.Sprintf("driver-%d-%d", s.now().UnixMilli(), time.Now().UnixNano()%1000) + } + if err := s.pg.Create(ctx, d); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return Driver{}, fmt.Errorf("%w: %s", ErrAlreadyExists, nick) + } + return Driver{}, err + } + return s.pg.Get(ctx, d.ID) +} + +// Get loads a driver by id. +func (s *Service) Get(ctx context.Context, id string) (Driver, error) { + return s.pg.Get(ctx, id) +} + +// GetByNickname loads a driver by nickname. +func (s *Service) GetByNickname(ctx context.Context, nick string) (Driver, error) { + nick = strings.ToUpper(strings.TrimSpace(nick)) + return s.pg.GetByNickname(ctx, nick) +} + +// List returns drivers with limit/offset and optional clan filter. +func (s *Service) List(ctx context.Context, limit, offset int, clanID string) ([]Driver, error) { + return s.pg.List(ctx, limit, offset, clanID) +} + +// UpdateInput is the patch payload. +type UpdateInput struct { + Name string + AvatarURL string + ClanID *string // nil = leave unchanged, &"" = clear, &"..." = set +} + +// Update applies a partial patch. +func (s *Service) Update(ctx context.Context, id string, in UpdateInput) (Driver, error) { + cur, err := s.pg.Get(ctx, id) + if err != nil { + return Driver{}, err + } + if in.Name != "" { + cur.Name = in.Name + } + if in.AvatarURL != "" { + cur.AvatarURL = in.AvatarURL + } + if in.ClanID != nil { + cur.ClanID = *in.ClanID + } + if err := s.pg.Update(ctx, cur); err != nil { + return Driver{}, err + } + return s.pg.Get(ctx, id) +} + +// Delete removes a driver. +func (s *Service) Delete(ctx context.Context, id string) error { + return s.pg.Delete(ctx, id) +} diff --git a/server/internal/drivers/store.go b/server/internal/drivers/store.go new file mode 100644 index 0000000..4fa6a4f --- /dev/null +++ b/server/internal/drivers/store.go @@ -0,0 +1,188 @@ +// 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. +package drivers + +import ( + "context" + "errors" + "fmt" + "regexp" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Errors returned by the service. +var ( + ErrNotFound = errors.New("driver not found") + ErrInvalidInput = errors.New("invalid input") + ErrAlreadyExists = errors.New("driver already exists") +) + +// nicknameRE enforces exactly 3 uppercase ASCII letters. +var nicknameRE = regexp.MustCompile(`^[A-Z]{3}$`) + +// IsValidNickname reports whether s is a valid driver nickname. +func IsValidNickname(s string) bool { return nicknameRE.MatchString(s) } + +// Driver is the canonical in-memory representation. +type Driver struct { + ID string + Nickname string + Name string + AvatarURL string + ClanID string + CreatedMs int64 + UpdatedMs int64 +} + +// PgStore is the Postgres-backed half of the package. +type PgStore struct { + pool *pgxpool.Pool +} + +// NewPgStore wraps an open pgxpool. +func NewPgStore(pool *pgxpool.Pool) *PgStore { + return &PgStore{pool: pool} +} + +// Exec exposes raw SQL for the seeder. +func (s *PgStore) Exec(ctx context.Context, sql string) (int64, error) { + tag, err := s.pool.Exec(ctx, sql) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +// Create inserts a driver. +func (s *PgStore) Create(ctx context.Context, d Driver) error { + now := time.Now().UnixMilli() + if d.CreatedMs == 0 { + d.CreatedMs = now + } + d.UpdatedMs = now + _, err := s.pool.Exec(ctx, ` + INSERT INTO drivers (id, nickname, name, avatar_url, clan_id, created_ms, updated_ms) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + d.ID, d.Nickname, d.Name, d.AvatarURL, nullableClan(d.ClanID), d.CreatedMs, d.UpdatedMs) + if err != nil { + return fmt.Errorf("insert driver: %w", err) + } + return nil +} + +func nullableClan(id string) any { + if id == "" { + return nil + } + return id +} + +// Get loads a driver by id. +func (s *PgStore) Get(ctx context.Context, id string) (Driver, error) { + row := s.pool.QueryRow(ctx, + `SELECT id, nickname, name, avatar_url, COALESCE(clan_id, ''), created_ms, updated_ms + FROM drivers WHERE id = $1`, id) + var d Driver + if err := row.Scan(&d.ID, &d.Nickname, &d.Name, &d.AvatarURL, &d.ClanID, &d.CreatedMs, &d.UpdatedMs); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Driver{}, ErrNotFound + } + return Driver{}, err + } + return d, nil +} + +// GetByNickname loads a driver by their 3-letter nickname. +func (s *PgStore) GetByNickname(ctx context.Context, nick string) (Driver, error) { + row := s.pool.QueryRow(ctx, + `SELECT id, nickname, name, avatar_url, COALESCE(clan_id, ''), created_ms, updated_ms + FROM drivers WHERE nickname = $1`, nick) + var d Driver + if err := row.Scan(&d.ID, &d.Nickname, &d.Name, &d.AvatarURL, &d.ClanID, &d.CreatedMs, &d.UpdatedMs); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Driver{}, ErrNotFound + } + return Driver{}, err + } + return d, nil +} + +// List returns all drivers, ordered by nickname. +func (s *PgStore) List(ctx context.Context, limit, offset int, clanID string) ([]Driver, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + q := `SELECT id, nickname, name, avatar_url, COALESCE(clan_id, ''), created_ms, updated_ms + FROM drivers` + args := []any{} + if clanID != "" { + q += ` WHERE clan_id = $1` + args = append(args, clanID) + args = append(args, limit, offset) + q += ` ORDER BY nickname ASC LIMIT $2 OFFSET $3` + } else { + args = append(args, limit, offset) + q += ` ORDER BY nickname ASC LIMIT $1 OFFSET $2` + } + rows, err := s.pool.Query(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]Driver, 0) + for rows.Next() { + var d Driver + if err := rows.Scan(&d.ID, &d.Nickname, &d.Name, &d.AvatarURL, &d.ClanID, &d.CreatedMs, &d.UpdatedMs); err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + +// Update applies a partial patch. +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 + WHERE id = $1`, + d.ID, d.Name, d.AvatarURL, nullableClan(d.ClanID), d.UpdatedMs) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// Delete removes a driver by id. +func (s *PgStore) Delete(ctx context.Context, id string) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM drivers WHERE id = $1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// Count returns the total number of drivers. +func (s *PgStore) Count(ctx context.Context) (int, error) { + var n int + row := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM drivers`) + if err := row.Scan(&n); err != nil { + return 0, err + } + return n, nil +} diff --git a/server/internal/lobby/lobby.go b/server/internal/lobby/lobby.go new file mode 100644 index 0000000..3f70015 --- /dev/null +++ b/server/internal/lobby/lobby.go @@ -0,0 +1,501 @@ +package lobby + +import ( + "context" + "errors" + "fmt" + "strings" + "sync/atomic" + "time" +) + +// Errors returned by the lobby service. +var ( + ErrRaceNotFound = errors.New("race not found") + ErrRaceFull = errors.New("race is full") + ErrRaceNotOpen = errors.New("race is not open for joining") + ErrRaceAlreadyExists = errors.New("race already exists") + ErrDriverNotFound = errors.New("driver not found") + ErrInvalidInput = errors.New("invalid input") +) + +// Options for CreateRace. +type CreateRaceOptions struct { + ID string // optional; auto-generated if empty + Name string // required + TrackID string // optional, defaults to "default" + MaxCars int // required, 1..8 + Laps int // 0 = time-based + TimeLimitS int // 0 = lap-based +} + +// DefaultMaxCars is the fallback when CreateRaceOptions doesn't specify. +const DefaultMaxCars = 4 + +// raceCounter assigns sequential IDs when caller doesn't provide one. +var raceCounter seqCounter + +// CreateRace registers a new race in the lobby. +// +// Rules: +// - race name is required (non-empty) +// - max_cars must be in [1, 8] +// - laps and time_limit_s can't both be 0 +// +// Races are owned by a physical track; there is no host driver. Drivers +// join via AddDriverToRace. +func (s *Service) CreateRace(opts CreateRaceOptions) (RaceMeta, error) { + name := strings.TrimSpace(opts.Name) + if name == "" { + return RaceMeta{}, fmt.Errorf("%w: name required", ErrInvalidInput) + } + max := opts.MaxCars + if max == 0 { + max = DefaultMaxCars + } + if max < 1 || max > 8 { + return RaceMeta{}, fmt.Errorf("%w: max_cars must be 1..8", ErrInvalidInput) + } + if opts.Laps == 0 && opts.TimeLimitS == 0 { + return RaceMeta{}, fmt.Errorf("%w: laps or time_limit_s required", ErrInvalidInput) + } + + s.mu.Lock() + id := opts.ID + if id == "" { + id = fmt.Sprintf("race-%d-%d", time.Now().Unix(), raceCounter.v.Add(1)) + } else if _, exists := s.races[id]; exists { + s.mu.Unlock() + return RaceMeta{}, ErrRaceAlreadyExists + } + + track := opts.TrackID + if track == "" { + track = "default" + } + + meta := RaceMeta{ + ID: id, + Name: name, + TrackID: track, + MaxCars: max, + Laps: opts.Laps, + TimeLimitS: opts.TimeLimitS, + DriverIDs: []string{}, + Status: RaceStatusLobby, + CreatedMs: time.Now().UnixMilli(), + } + + s.races[id] = &meta + hook := s.onRaceCreated + s.mu.Unlock() + + if hook != nil { + hook(meta) + } + s.bump("race_created", id, "") + s.mirror(func(ctx context.Context, p Persistence) error { return p.UpsertRace(ctx, meta) }) + return meta, nil +} + +// AddRace inserts a race into the in-memory lobby without triggering a +// persistence write. Used by RestoreFromDB where the row is already +// in Postgres. Bumps the lobby version so subscribers re-sync. +func (s *Service) AddRace(r RaceMeta) { + if r.ID == "" { + return + } + s.mu.Lock() + r2 := r + s.races[r.ID] = &r2 + s.mu.Unlock() + s.bump("race_restored", r.ID, "") +} + +// AddDriver registers a new driver or refreshes an existing one. +// If the driver already exists with a different name, the name is updated. +func (s *Service) AddDriver(id, name, country string) (DriverMeta, error) { + if id == "" { + return DriverMeta{}, fmt.Errorf("%w: id required", ErrInvalidInput) + } + if name == "" { + name = id + } + + s.mu.Lock() + now := time.Now().UnixMilli() + broadcastKind := "" + if existing, ok := s.drivers[id]; ok { + existing.LastSeenMs = now + if name != "" { + existing.Name = name + } + if country != "" { + existing.Country = country + } + if existing.Status == DriverStatusOffline { + existing.Status = DriverStatusIdle + broadcastKind = "driver_joined" + } + out := *existing + s.mu.Unlock() + if broadcastKind != "" { + s.bump(broadcastKind, "", id) + } + return out, nil + } + d := DriverMeta{ + ID: id, + Name: name, + Status: DriverStatusIdle, + ConnectedMs: now, + LastSeenMs: now, + Country: country, + AvatarHue: hashHue(id), + } + s.drivers[id] = &d + s.mu.Unlock() + s.bump("driver_joined", "", id) + s.mirror(func(ctx context.Context, p Persistence) error { + return p.UpsertDriver(ctx, d) + }) + return d, nil +} + +// RemoveDriver marks a driver as offline. The driver is kept in memory +// briefly to allow reconnect grace (see Snapshot filter). +func (s *Service) RemoveDriver(id string) { + var raceIDToRemove string + s.mu.Lock() + d, ok := s.drivers[id] + if !ok { + s.mu.Unlock() + return + } + // If still racing, treat as crash — remove from race. + if d.Status == DriverStatusRacing && d.RaceID != "" { + if race, ok := s.races[d.RaceID]; ok { + race.DriverIDs = removeString(race.DriverIDs, id) + if len(race.DriverIDs) == 0 { + delete(s.races, d.RaceID) + raceIDToRemove = d.RaceID + } + } + } + d.Status = DriverStatusOffline + d.LastSeenMs = time.Now().UnixMilli() + d.RaceID = "" + hook := s.onRaceRemoved + s.mu.Unlock() + + if raceIDToRemove != "" && hook != nil { + hook(raceIDToRemove) + } + s.bump("driver_left", "", id) + if raceIDToRemove != "" { + s.mirror(func(ctx context.Context, p Persistence) error { + return p.DeleteRace(context.Background(), raceIDToRemove) + }) + } + s.mirror(func(ctx context.Context, p Persistence) error { + return p.DeleteDriver(ctx, id) + }) +} + +// Heartbeat refreshes LastSeenMs (call periodically from session). +func (s *Service) Heartbeat(id string) { + s.mu.Lock() + if d, ok := s.drivers[id]; ok { + d.LastSeenMs = time.Now().UnixMilli() + } + s.mu.Unlock() +} + +// SetDriverProfile updates the denormalised display fields of a driver +// (nickname, avatar URL, clan). Safe to call on a missing driver (no-op). +func (s *Service) SetDriverProfile(id, nickname, avatarURL, clanID, clanTag string) { + s.mu.Lock() + var d *DriverMeta + if v, ok := s.drivers[id]; ok { + v.Nickname = nickname + v.AvatarURL = avatarURL + v.ClanID = clanID + v.ClanTag = clanTag + d = v + } + s.mu.Unlock() + if d != nil { + s.mirror(func(ctx context.Context, p Persistence) error { + return p.UpsertDriver(ctx, *d) + }) + } +} + +// AddDriverToRace moves a driver from idle into a race. Used by the +// client->server JoinRace flow (in addition to the race engine state). +// Idempotent: re-joining the same race is a no-op. +func (s *Service) AddDriverToRace(driverID, raceID string) error { + s.mu.Lock() + d, ok := s.drivers[driverID] + if !ok { + s.mu.Unlock() + return ErrDriverNotFound + } + r, ok := s.races[raceID] + if !ok { + s.mu.Unlock() + return ErrRaceNotFound + } + if r.Status != RaceStatusLobby { + s.mu.Unlock() + return fmt.Errorf("%w: status=%s", ErrRaceNotOpen, r.Status) + } + if len(r.DriverIDs) >= r.MaxCars { + s.mu.Unlock() + return ErrRaceFull + } + if containsString(r.DriverIDs, driverID) { + d.Status = DriverStatusRacing + d.RaceID = raceID + s.mu.Unlock() + return nil + } + r.DriverIDs = append(r.DriverIDs, driverID) + d.Status = DriverStatusRacing + d.RaceID = raceID + slot := len(r.DriverIDs) - 1 + s.mu.Unlock() + s.bump("race_updated", raceID, driverID) + s.mirror(func(ctx context.Context, p Persistence) error { + return p.AddDriver(ctx, raceID, driverID, slot) + }) + s.mirror(func(ctx context.Context, p Persistence) error { + return p.UpsertDriver(ctx, *d) + }) + return nil +} + +// RemoveDriverFromRace returns a driver to the lobby. +func (s *Service) RemoveDriverFromRace(driverID string) { + var raceIDRemoved string + s.mu.Lock() + d, ok := s.drivers[driverID] + if !ok { + s.mu.Unlock() + return + } + if d.RaceID == "" { + d.Status = DriverStatusIdle + s.mu.Unlock() + return + } + r, ok := s.races[d.RaceID] + if ok { + r.DriverIDs = removeString(r.DriverIDs, driverID) + if len(r.DriverIDs) == 0 { + delete(s.races, d.RaceID) + raceIDRemoved = d.RaceID + } + } + d.Status = DriverStatusIdle + d.RaceID = "" + prevRaceID := d.RaceID + hook := s.onRaceRemoved + s.mu.Unlock() + + if raceIDRemoved != "" && hook != nil { + hook(raceIDRemoved) + } + s.bump("race_updated", prevRaceID, driverID) + if prevRaceID != "" { + s.mirror(func(ctx context.Context, p Persistence) error { + return p.RemoveDriver(ctx, prevRaceID, driverID) + }) + } + s.mirror(func(ctx context.Context, p Persistence) error { + return p.UpsertDriver(ctx, *d) + }) + if raceIDRemoved != "" { + s.mirror(func(ctx context.Context, p Persistence) error { + return p.DeleteRace(ctx, raceIDRemoved) + }) + } +} + +// SetRaceStatus updates the status (used by Engine when race starts/ends). +func (s *Service) SetRaceStatus(raceID string, status RaceStatus) error { + s.mu.Lock() + r, ok := s.races[raceID] + if !ok { + s.mu.Unlock() + return ErrRaceNotFound + } + r.Status = status + if status == RaceStatusRacing && r.StartedMs == 0 { + r.StartedMs = time.Now().UnixMilli() + } + startedMs := r.StartedMs + var affected []DriverMeta + if status == RaceStatusFinished { + for _, did := range r.DriverIDs { + if d, ok := s.drivers[did]; ok { + d.Status = DriverStatusIdle + d.RaceID = "" + affected = append(affected, *d) + } + } + } + s.mu.Unlock() + s.bump("race_updated", raceID, "") + s.mirror(func(ctx context.Context, p Persistence) error { + return p.SetRaceStatus(ctx, raceID, status, startedMs) + }) + for i := range affected { + d := affected[i] + s.mirror(func(ctx context.Context, p Persistence) error { + return p.UpsertDriver(ctx, d) + }) + } + return nil +} + +// DeleteRace removes a race entirely. Drivers inside are returned to idle. +func (s *Service) DeleteRace(raceID string) error { + s.mu.Lock() + r, ok := s.races[raceID] + if !ok { + s.mu.Unlock() + return ErrRaceNotFound + } + for _, did := range r.DriverIDs { + if d, ok := s.drivers[did]; ok { + d.Status = DriverStatusIdle + d.RaceID = "" + } + } + delete(s.races, raceID) + hook := s.onRaceRemoved + s.mu.Unlock() + if hook != nil { + hook(raceID) + } + s.bump("race_removed", raceID, "") + s.mirror(func(ctx context.Context, p Persistence) error { + return p.DeleteRace(ctx, raceID) + }) + return nil +} + +// QuickPlay assigns a driver to the first available race, or creates +// a new one if none exist. Returns the chosen race. +func (s *Service) QuickPlay(driverID string, defaultMaxCars int) (RaceMeta, error) { + var candidateID string + s.mu.RLock() + for _, r := range s.races { + if r.Status == RaceStatusLobby && len(r.DriverIDs) < r.MaxCars { + candidateID = r.ID + break + } + } + s.mu.RUnlock() + + if candidateID != "" { + if err := s.AddDriverToRace(driverID, candidateID); err != nil { + return RaceMeta{}, err + } + return s.GetRace(candidateID) + } + driver, err := s.GetDriver(driverID) + if err != nil { + return RaceMeta{}, err + } + race, err := s.CreateRace(CreateRaceOptions{ + Name: driver.Name + "'s race", + MaxCars: defaultMaxCars, + Laps: 5, + }) + if err != nil { + return RaceMeta{}, err + } + if err := s.AddDriverToRace(driverID, race.ID); err != nil { + return RaceMeta{}, err + } + return s.GetRace(race.ID) +} + +// GetRace returns the current meta for a race (by value, safe to mutate). +func (s *Service) GetRace(raceID string) (RaceMeta, error) { + s.mu.RLock() + defer s.mu.RUnlock() + r, ok := s.races[raceID] + if !ok { + return RaceMeta{}, ErrRaceNotFound + } + return *r, nil +} + +// GetDriver returns the current meta for a driver. +func (s *Service) GetDriver(driverID string) (DriverMeta, error) { + s.mu.RLock() + defer s.mu.RUnlock() + d, ok := s.drivers[driverID] + if !ok { + return DriverMeta{}, ErrDriverNotFound + } + return *d, nil +} + +// ListRaces returns a snapshot copy of all races. +func (s *Service) ListRaces() []RaceMeta { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]RaceMeta, 0, len(s.races)) + for _, r := range s.races { + out = append(out, *r) + } + return out +} + +// ListDrivers returns a snapshot copy of all non-offline drivers. +func (s *Service) ListDrivers() []DriverMeta { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]DriverMeta, 0, len(s.drivers)) + for _, d := range s.drivers { + if d.Status != DriverStatusOffline { + out = append(out, *d) + } + } + return out +} + +// helpers ----------------------------------------------------------------- + +func removeString(s []string, v string) []string { + for i, x := range s { + if x == v { + return append(s[:i], s[i+1:]...) + } + } + return s +} + +func containsString(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + +// hashHue returns a stable 0..360 hue for the given string. +func hashHue(s string) int { + var h uint32 = 5381 + for i := 0; i < len(s); i++ { + h = h*33 ^ uint32(s[i]) + } + return int(h % 360) +} + +type seqCounter struct{ v atomic.Uint64 } \ No newline at end of file diff --git a/server/internal/lobby/lobby_test.go b/server/internal/lobby/lobby_test.go new file mode 100644 index 0000000..07e4b3a --- /dev/null +++ b/server/internal/lobby/lobby_test.go @@ -0,0 +1,120 @@ +package lobby + +import ( + "strings" + "testing" + "time" +) + +func TestAddDriver(t *testing.T) { + s := NewService() + d, err := s.AddDriver("d1", "Alice", "RU") + if err != nil { + t.Fatalf("AddDriver: %v", err) + } + if d.Status != DriverStatusIdle { + t.Errorf("expected idle, got %s", d.Status) + } + if d.Name != "Alice" { + t.Errorf("name: got %s", d.Name) + } +} + +func TestCreateRaceValidates(t *testing.T) { + s := NewService() + _, _ = s.AddDriver("d1", "Alice", "") + tests := []struct { + name string + opts CreateRaceOptions + want string // substring of error message or "" for OK + }{ + {"empty name", CreateRaceOptions{Name: "", MaxCars: 2, Laps: 1}, "name required"}, + {"max cars too low", CreateRaceOptions{Name: "r", MaxCars: 0, Laps: 1}, ""}, // defaults + {"max cars too high", CreateRaceOptions{Name: "r", MaxCars: 9, Laps: 1}, "max_cars must be"}, + {"no laps and no time", CreateRaceOptions{Name: "r", MaxCars: 2}, "laps or time_limit_s"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := s.CreateRace(tt.opts) + if tt.want == "" && err != nil { + t.Fatalf("expected ok, got %v", err) + } + if tt.want != "" && err == nil { + t.Fatalf("expected error containing %q, got nil", tt.want) + } + if tt.want != "" && err != nil && !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error %q does not contain %q", err.Error(), tt.want) + } + }) + } +} + +func TestCreateRaceEmpty(t *testing.T) { + s := NewService() + r, err := s.CreateRace(CreateRaceOptions{ + Name: "open", + TrackID: "default", + MaxCars: 4, + Laps: 5, + }) + if err != nil { + t.Fatalf("CreateRace: %v", err) + } + if r.Status != RaceStatusLobby { + t.Errorf("status: got %s", r.Status) + } + if len(r.DriverIDs) != 0 { + t.Errorf("expected empty drivers, got %d", len(r.DriverIDs)) + } + if r.TrackID != "default" { + t.Errorf("track: got %s", r.TrackID) + } +} + +func TestQuickPlayJoinsFirstRace(t *testing.T) { + s := NewService() + _, _ = s.AddDriver("d1", "Alice", "") + _, _ = s.AddDriver("d2", "Bob", "") + r1, _ := s.QuickPlay("d1", 4) + r2, err := s.QuickPlay("d2", 4) + if err != nil { + t.Fatalf("QuickPlay d2: %v", err) + } + if r2.ID != r1.ID { + t.Errorf("expected to join %s, got %s", r1.ID, r2.ID) + } + if len(r2.DriverIDs) != 2 { + t.Errorf("expected 2 drivers, got %d", len(r2.DriverIDs)) + } +} + +func TestSubscribeReceivesSnapshot(t *testing.T) { + s := NewService() + ch, cancel := s.Subscribe() + defer cancel() + // Subscribe pushes initial snapshot. + select { + case ev := <-ch: + if ev.kind != "snapshot" { + t.Fatalf("first event: kind=%s", ev.kind) + } + case <-time.After(time.Second): + t.Fatal("no initial snapshot") + } +} + +func TestSubscribeReceivesRaceCreated(t *testing.T) { + s := NewService() + ch, cancel := s.Subscribe() + defer cancel() + <-ch // drain initial snapshot + _, _ = s.AddDriver("d1", "Alice", "") + select { + case ev := <-ch: + if ev.kind != "driver_joined" { + t.Fatalf("expected driver_joined, got %s", ev.kind) + } + case <-time.After(time.Second): + t.Fatal("no event after AddDriver") + } +} diff --git a/server/internal/lobby/types.go b/server/internal/lobby/types.go new file mode 100644 index 0000000..71accf9 --- /dev/null +++ b/server/internal/lobby/types.go @@ -0,0 +1,293 @@ +// 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). +package lobby + +import ( + "context" + "log/slog" + "sync" + "sync/atomic" + "time" +) + +// RaceStatus is the lifecycle of a race. +type RaceStatus string + +const ( + RaceStatusLobby RaceStatus = "lobby" // waiting for drivers, no countdown yet + RaceStatusCountdown RaceStatus = "countdown" // countdown in progress + RaceStatusRacing RaceStatus = "racing" // live + RaceStatusFinished RaceStatus = "finished" // results recorded, race closes soon +) + +// RaceMeta describes a single race as it appears in the lobby. +type RaceMeta struct { + ID string `json:"id"` + Name string `json:"name"` + TrackID string `json:"track_id"` // logical track identifier ("oval-a", "figure-8", ...) + MaxCars int `json:"max_cars"` // capacity (PoC: 1..4) + Laps int `json:"laps"` // total laps (0 = time-based) + TimeLimitS int `json:"time_limit_s"` // seconds; 0 = lap-based only + DriverIDs []string `json:"driver_ids"` // joined drivers + Status RaceStatus `json:"status"` + CreatedMs int64 `json:"created_ms"` + StartedMs int64 `json:"started_ms,omitempty"` // 0 if not started yet +} + +// DriverStatus describes a driver as seen by the lobby. +type DriverStatus string + +const ( + DriverStatusIdle DriverStatus = "idle" // connected, not in any race + DriverStatusRacing DriverStatus = "racing" // in a race + DriverStatusOffline DriverStatus = "offline" // disconnected (kept briefly for reconnect grace) +) + +// DriverMeta describes a driver in the lobby. +type DriverMeta struct { + ID string `json:"id"` + Name string `json:"name"` + Nickname string `json:"nickname,omitempty"` // 3-letter unique tag (A-Z) + AvatarURL string `json:"avatar_url,omitempty"` + ClanID string `json:"clan_id,omitempty"` + ClanTag string `json:"clan_tag,omitempty"` // denormalised for UI + Status DriverStatus `json:"status"` + RaceID string `json:"race_id,omitempty"` // current race if Status=racing + ConnectedMs int64 `json:"connected_ms"` + LastSeenMs int64 `json:"last_seen_ms"` + Country string `json:"country,omitempty"` // optional, for UI + AvatarHue int `json:"avatar_hue,omitempty"` // 0..360, deterministic per id +} + +// Snapshot is the immutable view broadcast to clients. +type Snapshot struct { + GeneratedMs int64 `json:"generated_ms"` + Races []RaceMeta `json:"races"` + Drivers []DriverMeta `json:"drivers"` + Version uint64 `json:"version"` // monotonic, lets clients skip duplicates +} + +// Stats reports lightweight counters for /stats or /lobby summary. +type Stats struct { + RacesTotal int `json:"races_total"` + RacesOpen int `json:"races_open"` // status=lobby|countdown + RacesActive int `json:"races_active"` // status=racing + DriversIdle int `json:"drivers_idle"` + DriversTotal int `json:"drivers_total"` +} + +// DriverID is a type alias to avoid confusion with raw strings. +type DriverID = string +// RaceID is a type alias to avoid confusion with raw strings. +type RaceID = string + +// event is an internal broadcast (not exported on the wire). +type event struct { + kind string // "snapshot" | "race_created" | "race_removed" | "race_updated" | "driver_joined" | "driver_left" + snapshot Snapshot + raceID string + driverID string +} + +// Service owns lobby state. Safe for concurrent use. +type Service struct { + mu sync.RWMutex + races map[RaceID]*RaceMeta + drivers map[DriverID]*DriverMeta + version atomic.Uint64 + subsMu sync.RWMutex + subs map[chan event]struct{} + stopCh chan struct{} + doneCh chan struct{} + + // Hooks (set by main, called by lobby on lifecycle events). + // onRaceCreated is fired when a new race is registered in the lobby. + onRaceCreated func(RaceMeta) + // onRaceRemoved is fired when a race leaves the lobby (started, finished, deleted). + onRaceRemoved func(RaceID) + + // Optional persistence sink for live races and lobby drivers. If + // non-nil, mutations are mirrored best-effort. Set via + // SetPersistence. + persist Persistence +} + +// Persistence is the contract the lobby uses to mirror its in-memory +// state to durable storage. Implementations must be safe for concurrent +// use. The lobby never blocks on a persistence failure. +type Persistence interface { + UpsertRace(ctx context.Context, r RaceMeta) error + DeleteRace(ctx context.Context, raceID string) error + AddDriver(ctx context.Context, raceID, driverID string, slot int) error + RemoveDriver(ctx context.Context, raceID, driverID string) error + SetRaceStatus(ctx context.Context, raceID string, status RaceStatus, startedMs int64) error + UpsertDriver(ctx context.Context, d DriverMeta) error + DeleteDriver(ctx context.Context, driverID string) error +} + +// NewService creates a fresh lobby service. +func NewService() *Service { + return &Service{ + races: make(map[RaceID]*RaceMeta), + drivers: make(map[DriverID]*DriverMeta), + subs: make(map[chan event]struct{}), + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } +} + +// SetPersistence installs a write-through persistence sink. Pass nil to +// disable. After this call, mutations are mirrored to p in a detached +// goroutine; failures are silently dropped. +func (s *Service) SetPersistence(p Persistence) { + s.mu.Lock() + s.persist = p + s.mu.Unlock() +} + +// SetRaceLifecycleHook installs optional callbacks (use nil to clear). +func (s *Service) SetRaceLifecycleHook(onCreated func(RaceMeta), onRemoved func(RaceID)) { + s.mu.Lock() + defer s.mu.Unlock() + s.onRaceCreated = onCreated + s.onRaceRemoved = onRemoved +} + +// Subscribe returns a channel that receives events. The channel is +// closed when the service stops or the caller invokes the returned +// cancel func. +func (s *Service) Subscribe() (<-chan event, func()) { + ch := make(chan event, 16) + s.subsMu.Lock() + s.subs[ch] = struct{}{} + s.subsMu.Unlock() + + // Push initial snapshot immediately so the subscriber doesn't have to + // request one explicitly. + ch <- event{kind: "snapshot", snapshot: s.snapshot()} + return ch, func() { + s.subsMu.Lock() + if _, ok := s.subs[ch]; ok { + delete(s.subs, ch) + close(ch) + } + s.subsMu.Unlock() + } +} + +// Stop terminates the service. Safe to call multiple times. +func (s *Service) Stop() { + select { + case <-s.stopCh: + return + default: + close(s.stopCh) + s.subsMu.Lock() + for ch := range s.subs { + delete(s.subs, ch) + close(ch) + } + s.subsMu.Unlock() + close(s.doneCh) + } +} + +// Done blocks until Stop() is called. +func (s *Service) Done() <-chan struct{} { return s.doneCh } + +// snapshot builds an immutable Snapshot of current state. +func (s *Service) snapshot() Snapshot { + s.mu.RLock() + defer s.mu.RUnlock() + out := Snapshot{ + GeneratedMs: time.Now().UnixMilli(), + Races: make([]RaceMeta, 0, len(s.races)), + Drivers: make([]DriverMeta, 0, len(s.drivers)), + Version: s.version.Load(), + } + now := time.Now().UnixMilli() + for _, r := range s.races { + out.Races = append(out.Races, *r) + } + for _, d := range s.drivers { + // 30-second grace for reconnect. + if d.Status == DriverStatusOffline && now-d.LastSeenMs > 30_000 { + continue + } + out.Drivers = append(out.Drivers, *d) + } + return out +} + +// Snapshot returns the current state for HTTP/WS responses. +func (s *Service) Snapshot() Snapshot { return s.snapshot() } + +// Stats returns lightweight counters. +func (s *Service) Stats() Stats { + s.mu.RLock() + defer s.mu.RUnlock() + out := Stats{ + RacesTotal: len(s.races), + } + for _, r := range s.races { + switch r.Status { + case RaceStatusLobby, RaceStatusCountdown: + out.RacesOpen++ + case RaceStatusRacing: + out.RacesActive++ + } + } + for _, d := range s.drivers { + if d.Status == DriverStatusOffline { + continue + } + out.DriversTotal++ + if d.Status == DriverStatusIdle { + out.DriversIdle++ + } + } + return out +} + +// bump increments the version and notifies subscribers. +func (s *Service) bump(kind string, raceID, driverID string) { + s.version.Add(1) + snap := s.snapshot() + ev := event{kind: kind, snapshot: snap, raceID: raceID, driverID: driverID} + s.subsMu.RLock() + defer s.subsMu.RUnlock() + for ch := range s.subs { + select { + case ch <- ev: + default: + // Slow subscriber — drop. They'll re-sync via the next snapshot. + } + } +} + +// mirror dispatches a write-through persistence call if a sink is +// installed. Failures are logged but never block the caller. Always +// runs in a detached goroutine. +func (s *Service) mirror(fn func(ctx context.Context, p Persistence) error) { + s.mu.RLock() + p := s.persist + s.mu.RUnlock() + if p == nil { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := fn(ctx, p); err != nil { + slog.Warn("lobby persist failed", "err", err) + } + }() +} \ No newline at end of file diff --git a/server/internal/races/keyset.go b/server/internal/races/keyset.go new file mode 100644 index 0000000..18418e0 --- /dev/null +++ b/server/internal/races/keyset.go @@ -0,0 +1,57 @@ +package races + +import ( + "encoding/base64" + "fmt" + "strconv" + "strings" +) + +// Cursor is a keyset cursor: opaque to the client, encoded as +// base64url(":") by Encode. Order: descending by ms, then by id. +// +// Empty cursor = "start from the newest". +type Cursor struct { + Ms int64 + ID string +} + +// String returns a human-readable representation (for debug logs). +func (c Cursor) String() string { + return fmt.Sprintf("%d:%s", c.Ms, c.ID) +} + +// IsZero reports whether the cursor is the zero value. +func (c Cursor) IsZero() bool { + return c.Ms == 0 && c.ID == "" +} + +// Encode renders the cursor for an HTTP query string. +func (c Cursor) Encode() string { + if c.IsZero() { + return "" + } + raw := c.String() + return base64.RawURLEncoding.EncodeToString([]byte(raw)) +} + +// DecodeCursor parses a cursor produced by Cursor.Encode. +// Returns the zero cursor on empty input. +func DecodeCursor(s string) (Cursor, error) { + if s == "" { + return Cursor{}, nil + } + raw, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return Cursor{}, fmt.Errorf("%w: bad cursor encoding: %v", ErrInvalidInput, err) + } + parts := strings.SplitN(string(raw), ":", 2) + if len(parts) != 2 { + return Cursor{}, fmt.Errorf("%w: cursor must be :", ErrInvalidInput) + } + ms, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return Cursor{}, fmt.Errorf("%w: cursor ms: %v", ErrInvalidInput, err) + } + return Cursor{Ms: ms, ID: parts[1]}, nil +} diff --git a/server/internal/races/live_store.go b/server/internal/races/live_store.go new file mode 100644 index 0000000..58b4d87 --- /dev/null +++ b/server/internal/races/live_store.go @@ -0,0 +1,75 @@ +package races + +import ( + "context" + + "github.com/x0gp/server/internal/lobby" +) + +// LiveStore is a thin facade over PgStore that exposes only the live +// race persistence methods. It implements the lobby.Persistence +// contract so the lobby can write through it. +// +// Methods on this type are kept separate from the rest of PgStore +// purely for clarity: the lobby-facing surface is small and +// well-defined. All actual work is delegated to PgStore. +type LiveStore struct { + pg *PgStore +} + +// NewLiveStore wraps an existing PgStore. +func NewLiveStore(pg *PgStore) *LiveStore { return &LiveStore{pg: pg} } + +func (s *LiveStore) UpsertRace(ctx context.Context, r lobby.RaceMeta) error { + return s.pg.UpsertLive(ctx, r) +} + +func (s *LiveStore) DeleteRace(ctx context.Context, raceID string) error { + return s.pg.DeleteLive(ctx, raceID) +} + +func (s *LiveStore) AddDriver(ctx context.Context, raceID, driverID string, slot int) error { + return s.pg.AddLiveDriver(ctx, raceID, driverID, slot) +} + +func (s *LiveStore) RemoveDriver(ctx context.Context, raceID, driverID string) error { + return s.pg.RemoveLiveDriver(ctx, raceID, driverID) +} + +func (s *LiveStore) SetRaceStatus(ctx context.Context, raceID string, status lobby.RaceStatus, startedMs int64) error { + return s.pg.SetLiveStatus(ctx, raceID, status, startedMs) +} + +func (s *LiveStore) UpsertDriver(ctx context.Context, d lobby.DriverMeta) error { + return s.pg.UpsertLobbyDriver(ctx, d) +} + +func (s *LiveStore) DeleteDriver(ctx context.Context, driverID string) error { + return s.pg.DeleteLobbyDriver(ctx, driverID) +} + +// ListAllRaces returns every live row in the unified races table. +// Used by RestoreFromDB to rehydrate the in-memory lobby. +func (s *LiveStore) ListAllRaces(ctx context.Context) ([]lobby.RaceMeta, error) { + return s.pg.ListAllRaces(ctx) +} + +// ListAllDrivers returns all lobby drivers from the lobby_drivers table. +func (s *LiveStore) ListAllDrivers(ctx context.Context) ([]lobby.DriverMeta, error) { + return s.pg.ListLobbyDrivers(ctx) +} + +// ListLivePaged exposes a keyset page of live races. +func (s *LiveStore) ListLivePaged(ctx context.Context, statuses []string, trackID string, cur Cursor, limit int) ([]lobby.RaceMeta, error) { + return s.pg.ListLivePaged(ctx, statuses, trackID, cur, limit) +} + +// ListUpcoming returns the next N open live races. +func (s *LiveStore) ListUpcoming(ctx context.Context, limit int) ([]lobby.RaceMeta, error) { + return s.pg.ListLiveUpcoming(ctx, limit) +} + +// Exec exposes raw SQL for the seeder. +func (s *LiveStore) Exec(ctx context.Context, sql string) (int64, error) { + return s.pg.Exec(ctx, sql) +} diff --git a/server/internal/races/seed/seed.go b/server/internal/races/seed/seed.go new file mode 100644 index 0000000..d173a46 --- /dev/null +++ b/server/internal/races/seed/seed.go @@ -0,0 +1,562 @@ +package seed + +import ( + "context" + "fmt" + "math/rand" + "time" + + "github.com/x0gp/server/internal/clans" + "github.com/x0gp/server/internal/drivers" + "github.com/x0gp/server/internal/lobby" + "github.com/x0gp/server/internal/races" +) + +// DefaultCounts are the volumes produced by Run. +type DefaultCounts struct { + Finished int // default 30 + Live int // default 5 + Plans int // default 5 +} + +// DefaultSeed is the deterministic seed. +const DefaultSeed = 0xA1B2C3D4 + +// DefaultNow is overridable for tests. +var DefaultNow = time.Now + +// Track catalogue used by the seeder. Loaded from the tracks table at +// run time. If no tracks exist, the seeder falls back to ["default"]. +var fallbackTracks = []string{"default"} + +// Default driver nicknames used when the drivers table is empty. Three +// uppercase letters each (per the drivers table constraint). +var defaultDriverSeeds = []struct { + nick string + name string +}{ + {"ACE", "Alice"}, + {"BOB", "Bob"}, + {"CAR", "Carol"}, + {"DAV", "Dave"}, + {"EVE", "Eve"}, + {"FRA", "Frank"}, + {"GRA", "Grace"}, + {"HEI", "Heidi"}, +} + +// Default clans. +var defaultClanSeeds = []struct { + tag string + name string +}{ + {"ACE", "Ace Racing"}, + {"RND", "Rounders"}, + {"SPD", "Speed Demons"}, +} + +// Options configures Run. +type Options struct { + Counts DefaultCounts + Seed int64 + Reset bool + Now time.Time +} + +// Summary is the result of Run. +type Summary struct { + FinishedInserted int + LiveCreated int + PlansInserted int + QueueInserted int + Duration time.Duration +} + +// Runner is a self-contained seeder wired to the package's pg store and +// lobby. Hold one per process; safe to call Run multiple times. +type Runner struct { + pg *races.PgStore + lb *lobby.Service + live *races.LiveStore + clansPg *clans.PgStore + driversPg *drivers.PgStore + opt Options + rng *rand.Rand + now time.Time + + // Filled by loadInputs. + trackIDs []string + driverIDs []string // driver ids in the order they exist after seed + driverInfo []seedDriver // id, nick, name, clanID, clanTag + clanByID map[string]string // clan id -> tag (for fast profile updates) +} + +type seedDriver struct { + ID string + Nickname string + Name string + ClanID string + ClanTag string +} + +// NewRunner constructs a Runner. +func NewRunner(pg *races.PgStore, lb *lobby.Service, live *races.LiveStore, clansPg *clans.PgStore, driversPg *drivers.PgStore) *Runner { + return &Runner{pg: pg, lb: lb, live: live, clansPg: clansPg, driversPg: driversPg} +} + +// Run executes the seed against the supplied services. +// +// Order of operations: +// +// 1. (optional) reset: TRUNCATE finished_races, race_plans, race_queue; +// remove all live races from the lobby; remove the auto-seeded drivers +// from the lobby. +// 2. Insert N finished races into Postgres. These do NOT touch the lobby. +// 3. Create M live races in the lobby (mix of statuses). +// 4. Insert K race plans into Postgres. The scheduler will materialise +// them on its next tick (start_at_ms is in the future). +// 5. Enqueue the first two drivers for the next 2 live races. +func (r *Runner) Run(ctx context.Context, opt Options) (Summary, error) { + if opt.Seed == 0 { + opt.Seed = DefaultSeed + } + if opt.Now.IsZero() { + opt.Now = DefaultNow() + } + if opt.Counts.Finished == 0 { + opt.Counts.Finished = 30 + } + if opt.Counts.Live == 0 { + opt.Counts.Live = 5 + } + if opt.Counts.Plans == 0 { + opt.Counts.Plans = 5 + } + + start := time.Now() + var s Summary + + if opt.Reset { + if err := r.resetAll(ctx); err != nil { + return s, fmt.Errorf("reset: %w", err) + } + } + + r.rng = rand.New(rand.NewSource(opt.Seed)) + r.now = opt.Now + + // 0. Seed drivers and clans (idempotent). Then load real track ids + // from the catalog so race_plans / finished_races only reference + // tracks that actually exist in the DB. + if err := r.seedDriversAndClans(ctx); err != nil { + return s, fmt.Errorf("seed drivers/clans: %w", err) + } + if err := r.loadTracks(ctx); err != nil { + return s, fmt.Errorf("load tracks: %w", err) + } + if err := r.loadDrivers(ctx); err != nil { + return s, fmt.Errorf("load drivers: %w", err) + } + + // 1. Finished races. + for i := 0; i < opt.Counts.Finished; i++ { + row := r.makeFinished(i) + if err := r.pg.InsertFinished(ctx, row); err != nil { + return s, fmt.Errorf("insert finished %d: %w", i, err) + } + s.FinishedInserted++ + } + + // 2. Live races in the lobby. + statuses := []lobby.RaceStatus{ + lobby.RaceStatusLobby, + lobby.RaceStatusLobby, + lobby.RaceStatusCountdown, + lobby.RaceStatusRacing, + lobby.RaceStatusLobby, + } + for i := 0; i < opt.Counts.Live && i < len(statuses); i++ { + meta, err := r.createLive(statuses[i], i) + if err != nil { + return s, fmt.Errorf("create live %d: %w", i, err) + } + if statuses[i] != lobby.RaceStatusLobby { + if err := r.lb.SetRaceStatus(meta.ID, statuses[i]); err != nil { + return s, fmt.Errorf("set status %s: %w", meta.ID, err) + } + // Flush the new status synchronously so the DB row is in + // sync with in-memory (mirror goroutines may not have run + // yet). + if r.live != nil { + flushCtx, flushCancel := context.WithTimeout(ctx, 2*time.Second) + cur, err := r.lb.GetRace(meta.ID) + if err == nil { + _ = r.live.SetRaceStatus(flushCtx, cur.ID, cur.Status, cur.StartedMs) + } + flushCancel() + } + } + s.LiveCreated++ + + // First two live races: enqueue first two drivers on this race. + if i < 2 { + for _, d := range r.driverInfo[:2] { + if _, err := r.pg.Enqueue(ctx, d.ID, meta.ID, ""); err != nil { + return s, fmt.Errorf("enqueue %s: %w", d.ID, err) + } + s.QueueInserted++ + } + } + } + + // 3. Race plans. Mix of one-shot and recurring. + planShapes := []planShape{ + {intervalS: 0, offsetS: 5 * 60}, // one-shot in 5 min + {intervalS: 60, offsetS: 30 * 60}, // every minute, start in 30 min + {intervalS: 3600, count: 24, offsetS: 60 * 60}, // hourly x24, start in 1h + {intervalS: 0, offsetS: 6 * 3600}, // one-shot in 6h + {intervalS: 86400, offsetS: 24 * 3600}, // daily, start tomorrow + } + for i := 0; i < opt.Counts.Plans && i < len(planShapes); i++ { + ps := planShapes[i] + p := r.makePlan(i, ps) + if err := r.pg.CreatePlan(ctx, p); err != nil { + return s, fmt.Errorf("create plan %d: %w", i, err) + } + s.PlansInserted++ + } + + s.Duration = time.Since(start) + return s, nil +} + +type planShape struct { + intervalS int + count int + offsetS int +} + +// resetAll wipes the seed-managed rows in the unified races table +// (only finished | cancelled; live rows are removed via lobby +// cascade), plus the plan and queue tables. Drivers and clans are +// kept (they're independent of races). +func (r *Runner) resetAll(ctx context.Context) error { + if _, err := r.pg.Exec(ctx, + `DELETE FROM races WHERE status IN ('finished','cancelled')`); err != nil { + return err + } + if _, err := r.pg.Exec(ctx, + `TRUNCATE TABLE race_plans, race_queue`); err != nil { + return err + } + for _, m := range r.lb.ListRaces() { + _ = r.lb.DeleteRace(m.ID) + } + if _, err := r.pg.Exec(ctx, `TRUNCATE TABLE lobby_drivers`); err != nil { + return err + } + return nil +} + +// seedDriversAndClans inserts the default driver and clan set if the +// tables are empty. Idempotent: existing rows are left alone. +func (r *Runner) seedDriversAndClans(ctx context.Context) error { + clanCount, err := r.clansPg.Count(ctx) + if err != nil { + return fmt.Errorf("count clans: %w", err) + } + if clanCount == 0 { + now := time.Now().UnixMilli() + for i, cs := range defaultClanSeeds { + c := clans.Clan{ + ID: fmt.Sprintf("clan-seed-%03d", i+1), + Tag: cs.tag, + Name: cs.name, + AvatarURL: fmt.Sprintf("https://cdn.example.com/clans/%s.png", cs.tag), + CreatedMs: now, + UpdatedMs: now, + } + if err := r.clansPg.Create(ctx, c); err != nil { + return fmt.Errorf("insert clan %s: %w", cs.tag, err) + } + } + } + drvCount, err := r.driversPg.Count(ctx) + if err != nil { + return fmt.Errorf("count drivers: %w", err) + } + if drvCount == 0 { + now := time.Now().UnixMilli() + // Round-robin: each driver assigned to one of the seeded clans. + clanIDs := make([]string, 0, len(defaultClanSeeds)) + for i := range defaultClanSeeds { + clanIDs = append(clanIDs, fmt.Sprintf("clan-seed-%03d", i+1)) + } + for i, ds := range defaultDriverSeeds { + d := drivers.Driver{ + ID: fmt.Sprintf("driver-seed-%03d", i+1), + Nickname: ds.nick, + Name: ds.name, + AvatarURL: fmt.Sprintf("https://cdn.example.com/drivers/%s.png", ds.nick), + ClanID: clanIDs[i%len(clanIDs)], + CreatedMs: now, + UpdatedMs: now, + } + if err := r.driversPg.Create(ctx, d); err != nil { + return fmt.Errorf("insert driver %s: %w", ds.nick, err) + } + } + } + return nil +} + +// loadTracks fills r.trackIDs from the tracks table. Falls back to +// fallbackTracks if the table is empty. +func (r *Runner) loadTracks(ctx context.Context) error { + ids, err := r.pg.ListTrackIDs(ctx) + if err != nil { + return err + } + if len(ids) == 0 { + ids = fallbackTracks + } + r.trackIDs = ids + return nil +} + +// loadDrivers fills r.driverIDs and r.driverInfo from the drivers table +// in the DB, then propagates the profile (nickname/avatar/clan) to the +// in-memory lobby drivers. +func (r *Runner) loadDrivers(ctx context.Context) error { + // Pull all drivers, ordered by nickname. + clanByID := make(map[string]string) + clansList, err := r.clansPg.List(ctx, 200, 0) + if err != nil { + return fmt.Errorf("list clans: %w", err) + } + for _, c := range clansList { + clanByID[c.ID] = c.Tag + } + r.clanByID = clanByID + + drvList, err := r.driversPg.List(ctx, 200, 0, "") + if err != nil { + return fmt.Errorf("list drivers: %w", err) + } + r.driverIDs = r.driverIDs[:0] + r.driverInfo = r.driverInfo[:0] + for _, d := range drvList { + r.driverIDs = append(r.driverIDs, d.ID) + r.driverInfo = append(r.driverInfo, seedDriver{ + ID: d.ID, + Nickname: d.Nickname, + Name: d.Name, + ClanID: d.ClanID, + ClanTag: clanByID[d.ClanID], + }) + } + return nil +} + +// --------------------------------------------------------------------------- +// Generators +// --------------------------------------------------------------------------- + +func (r *Runner) makeFinished(idx int) races.FinishedRace { + rng := r.rng + now := r.now + // Spread across the last 30 days. + back := time.Duration(30*24-rng.Intn(30*24-1)) * time.Hour + finishedMs := now.Add(-back).UnixMilli() + startedMs := finishedMs - randDuration(rng, 90, 600).Milliseconds() + + track := r.trackIDs[rng.Intn(len(r.trackIDs))] + laps := trackLaps(track) + + // Pick 2..4 random drivers from the seeded set. + shuffled := append([]seedDriver(nil), r.driverInfo...) + rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) + n := 2 + rng.Intn(3) // 2..4 drivers + if n > len(shuffled) { + n = len(shuffled) + } + picked := shuffled[:n] + driverIDs := make([]string, n) + for i, d := range picked { + driverIDs[i] = d.ID + } + + winner := picked[rng.Intn(len(picked))] + totalLaps := laps + if rng.Intn(5) == 0 { + // ~20% DNF/partial. + totalLaps = rng.Intn(laps) + } + bestLapMs := int64(20_000 + rng.Intn(8_000)) + + // Podium: top-3 by total time. The winner always finishes; the + // remaining drivers are sorted by a per-driver total time derived + // from the winner's best lap. + perDriver := make([]int64, len(picked)) + for i, d := range picked { + if d.ID == winner.ID { + perDriver[i] = int64(totalLaps) * bestLapMs + } else { + perDriver[i] = int64(totalLaps)*bestLapMs + int64(2+rng.Intn(15))*1000 + } + } + type dp struct { + d seedDriver + time int64 + } + all := make([]dp, len(picked)) + for i := range picked { + all[i] = dp{picked[i], perDriver[i]} + } + // Insertion sort by time ascending. + for i := 1; i < len(all); i++ { + for j := i; j > 0 && all[j-1].time > all[j].time; j-- { + all[j-1], all[j] = all[j], all[j-1] + } + } + podium := make([]races.PodiumEntry, 0, 3) + for i := 0; i < len(all) && i < 3; i++ { + podium = append(podium, races.PodiumEntry{ + Position: i + 1, + DriverID: all[i].d.ID, + Name: all[i].d.Nickname, + TotalTimeMs: all[i].time, + }) + } + + return races.FinishedRace{ + ID: fmt.Sprintf("seed-finished-%03d", idx+1), + Name: fmt.Sprintf("Seed Race #%d (%s)", idx+1, track), + TrackID: track, + MaxCars: 4, + Laps: laps, + TimeLimitS: 0, + DriverIDs: driverIDs, + Status: "finished", + CreatedMs: finishedMs - randDuration(rng, 5, 120).Milliseconds(), + StartedMs: startedMs, + FinishedMs: finishedMs, + DurationMs: finishedMs - startedMs, + TotalLaps: totalLaps, + TotalDrivers: len(driverIDs), + WinnerDriverID: winner.ID, + WinnerName: winner.Nickname, + BestLapMs: bestLapMs, + Podium: podium, + } +} + +func (r *Runner) createLive(status lobby.RaceStatus, idx int) (lobby.RaceMeta, error) { + rng := r.rng + track := r.trackIDs[rng.Intn(len(r.trackIDs))] + + meta, err := r.lb.CreateRace(lobby.CreateRaceOptions{ + Name: fmt.Sprintf("Live %s #%d", track, idx+1), + TrackID: track, + MaxCars: 4, + Laps: trackLaps(track), + TimeLimitS: 0, + }) + if err != nil { + return lobby.RaceMeta{}, err + } + // Add 1..3 random drivers into the race. We re-add them to refresh + // any prior lobby state, then attach the profile (nickname/avatar/ + // clan) so the WS broadcast carries rich metadata. + shuffled := append([]seedDriver(nil), r.driverInfo...) + rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) + n := 1 + rng.Intn(3) + if n > len(shuffled) { + n = len(shuffled) + } + for i := 0; i < n; i++ { + d := shuffled[i] + r.lb.RemoveDriver(d.ID) + if _, err := r.lb.AddDriver(d.ID, d.Name, ""); err != nil { + continue + } + r.lb.SetDriverProfile(d.ID, d.Nickname, "", d.ClanID, d.ClanTag) + if err := r.lb.AddDriverToRace(d.ID, meta.ID); err != nil { + // ignore: race may be at capacity + } + } + // Synchronously flush the race to the DB so the persistence mirror + // (which uses detached goroutines) doesn't race with subsequent + // status changes. The mirror calls in lobby.Service are still + // fired; this upsert is a guarantee, not a replacement. + if r.live != nil { + flushCtx, flushCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer flushCancel() + // Re-fetch the race from the in-memory lobby to get the latest + // driver list and started_ms. + cur, err := r.lb.GetRace(meta.ID) + if err == nil { + _ = r.live.UpsertRace(flushCtx, cur) + for slot, did := range cur.DriverIDs { + _ = r.live.AddDriver(flushCtx, cur.ID, did, slot) + } + } + } + _ = status + return meta, nil +} + +func (r *Runner) makePlan(idx int, ps planShape) races.RacePlan { + rng := r.rng + now := r.now + track := r.trackIDs[rng.Intn(len(r.trackIDs))] + startAt := now.Add(time.Duration(ps.offsetS) * time.Second).UnixMilli() + return races.RacePlan{ + ID: fmt.Sprintf("seed-plan-%03d", idx+1), + Name: fmt.Sprintf("Seed Plan #%d (%s)", idx+1, track), + TrackID: track, + MaxCars: 4, + Laps: trackLaps(track), + TimeLimitS: 0, + StartAtMs: startAt, + IntervalS: ps.intervalS, + Count: ps.count, + Enabled: true, + NextFireMs: startAt, + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func trackLaps(track string) int { + switch track { + case "nordschleife": + return 3 + case "monza": + return 12 + case "spa": + return 10 + case "silverstone": + return 8 + case "suzuka": + return 9 + default: + return 5 + } +} + +func randDuration(rng *rand.Rand, loSec, hiSec int) time.Duration { + return time.Duration(loSec+rng.Intn(hiSec-loSec+1)) * time.Second +} + +func removeFrom(in []string, v string) []string { + out := make([]string, 0, len(in)-1) + for _, x := range in { + if x != v { + out = append(out, x) + } + } + return out +} diff --git a/server/internal/races/service.go b/server/internal/races/service.go new file mode 100644 index 0000000..2db58d0 --- /dev/null +++ b/server/internal/races/service.go @@ -0,0 +1,714 @@ +package races + +import ( + "context" + "fmt" + "sort" + "sync" + "time" + + "github.com/x0gp/server/internal/lobby" +) + +// Service composes lobby.Service (live) with PgStore (finished/plans/queue) +// and exposes the read/join methods used by the HTTP handlers and the +// scheduler. +type Service struct { + pg *PgStore + lobby *lobby.Service + live *LiveStore // optional: when set, live reads go through DB + now func() time.Time + maxIDs int +} + +// NewService wires a Service. now is overridable for tests. +func NewService(pg *PgStore, lb *lobby.Service) *Service { + return &Service{ + pg: pg, + lobby: lb, + now: time.Now, + maxIDs: 3, + } +} + +// SetLiveStore installs the LiveStore (DB-backed live races). When set, +// the read side of the races list pulls from Postgres instead of the +// in-memory lobby. Writes still go through lobby.Service. +func (s *Service) SetLiveStore(live *LiveStore) { + s.live = live +} + +// RestoreFromDB rehydrates the in-memory lobby from the live_races and +// lobby_drivers tables. Called at startup so that active races and +// driver presence survive a server restart. If LiveStore is not +// configured this is a no-op. +func (s *Service) RestoreFromDB(ctx context.Context) (int, int, error) { + if s.live == nil { + return 0, 0, nil + } + races, err := s.live.ListAllRaces(ctx) + if err != nil { + return 0, 0, fmt.Errorf("restore races: %w", err) + } + drivers, err := s.live.ListAllDrivers(ctx) + if err != nil { + return 0, 0, fmt.Errorf("restore drivers: %w", err) + } + // Restore drivers first so the race restore can attach them. + for _, d := range drivers { + // We bypass the normal AddDriver because we don't want to + // re-trigger a persistence write for already-persisted rows. + // Use the internal helper if available; otherwise go through + // AddDriver+SetDriverProfile and accept a no-op mirror. + _, _ = s.lobby.AddDriver(d.ID, d.Name, "") + s.lobby.SetDriverProfile(d.ID, d.Nickname, d.AvatarURL, d.ClanID, d.ClanTag) + } + for _, r := range races { + // We bypass CreateRace to avoid duplicating persistence writes + // for races that are already in the DB. AddRace (a new helper + // on lobby.Service) inserts the race without mirroring. + s.lobby.AddRace(r) + for _, did := range r.DriverIDs { + _ = s.lobby.AddDriverToRace(did, r.ID) + } + } + return len(races), len(drivers), nil +} + +// SetMaxUpcoming overrides the default "next N" queue size (default 3). +func (s *Service) SetMaxUpcoming(n int) { + if n > 0 && n <= 16 { + s.maxIDs = n + } +} + +// --------------------------------------------------------------------------- +// List (keyset, mixed live + finished) +// --------------------------------------------------------------------------- + +// ListFilter is the parsed query for ListRaces. +type ListFilter struct { + Statuses []StatusFilter // resolved filter; empty = all + TrackID string + Cursor Cursor + Limit int +} + +// RaceItem is one row in the paginated result. +type RaceItem struct { + Source string // "live" | "finished" + Meta lobby.RaceMeta // always populated + // Podium is populated for finished races only. + Podium []PodiumEntry +} + +// ListResult is a keyset page. +type ListResult struct { + Items []RaceItem + NextCursor string // empty when no more pages +} + +// ListRaces returns one keyset page of races matching the filter. +// +// Pagination strategy: live races (in-memory) are stable per snapshot but +// don't have a DB-side index. To honour the "keyset" contract we page +// live and finished sources independently and merge by sort key: +// +// * Live races use started_ms if set, otherwise created_ms. +// * Finished races use finished_ms. +// +// Pages are pulled live-first when any "active" status is in the filter, +// or finished-first when status=finished. The cursor encodes +// ("live"|"finished", sort_ms, race_id) so successive pages stay in order. +func (s *Service) ListRaces(ctx context.Context, f ListFilter) (ListResult, error) { + limit := f.Limit + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + + wantFinished := false + wantLive := false + for _, st := range f.Statuses { + if st == StatusAll { + wantLive = true + wantFinished = true + break + } + if st.matchesLive("finished") || st == StatusFinished { + wantFinished = true + } + if st.matchesLive("lobby") || st == StatusLobby || + st == StatusRacing { + wantLive = true + } + } + + // Parse cursor split (source/ms/id) if present. + curSource, curMs, curID := "live", int64(0), "" + if !f.Cursor.IsZero() { + curSource, curMs, curID = splitCursor(f.Cursor) + } + + // Collect candidates from each source up to (limit+1) entries. + out := make([]RaceItem, 0, limit+1) + + if wantLive { + live, err := s.collectLive(ctx, f.Statuses, f.TrackID, curSource, curMs, curID, limit+1) + if err != nil { + return ListResult{}, err + } + out = append(out, live...) + } + if wantFinished { + finished, err := s.collectFinished(ctx, f.Statuses, f.TrackID, curSource, curMs, curID, limit+1) + if err != nil { + return ListResult{}, err + } + out = append(out, finished...) + } + + // Sort by (sort_ms DESC, id ASC), stable. + sort.SliceStable(out, func(i, j int) bool { + mi := sortMs(out[i].Meta) + mj := sortMs(out[j].Meta) + if mi != mj { + return mi > mj + } + return out[i].Meta.ID < out[j].Meta.ID + }) + + hasMore := false + if len(out) > limit { + hasMore = true + out = out[:limit] + } + + res := ListResult{Items: out} + if hasMore && len(out) > 0 { + last := out[len(out)-1] + res.NextCursor = Cursor{ + Ms: sortMs(last.Meta), + ID: last.Meta.ID, + }.Encode() + } + return res, nil +} + +func sortMs(m lobby.RaceMeta) int64 { + if m.Status == lobby.RaceStatusFinished { + // Finished meta from PgStore has no FinishedMs field; StartedMs is the + // best proxy for the original finished time within the lobby shape. + if m.StartedMs > 0 { + return m.StartedMs + } + return m.CreatedMs + } + if m.StartedMs > 0 { + return m.StartedMs + } + return m.CreatedMs +} + +// collectLive fetches one page of live races. If s.live (LiveStore) is +// set, the data comes from Postgres; otherwise it falls back to the +// in-memory lobby. +func (s *Service) collectLive(ctx context.Context, filters []StatusFilter, trackID, curSource string, curMs int64, curID string, limit int) ([]RaceItem, error) { + if s.live != nil && curSource == "live" { + return s.collectLiveDB(ctx, filters, trackID, curMs, curID, limit) + } + // Fallback: in-memory lobby. The in-memory branch still applies the + // cursor manually because the legacy splitCursor format encodes the + // source in the id suffix. + all := s.lobby.ListRaces() + out := make([]RaceItem, 0, limit) + for _, r := range all { + if !statusMatches(filters, string(r.Status)) { + continue + } + if trackID != "" && r.TrackID != trackID { + continue + } + sortKey := sortMs(r) + if curSource == "live" { + if sortKey < curMs || (sortKey == curMs && r.ID >= curID) { + continue + } + } + out = append(out, RaceItem{Source: "live", Meta: r}) + if len(out) >= limit { + break + } + } + return out, nil +} + +func (s *Service) collectLiveDB(ctx context.Context, filters []StatusFilter, trackID string, curMs int64, curID string, limit int) ([]RaceItem, error) { + statuses := filterToStatusStrings(filters) + cur := Cursor{Ms: curMs, ID: curID} + rows, err := s.live.ListLivePaged(ctx, statuses, trackID, cur, limit) + if err != nil { + return nil, fmt.Errorf("list live: %w", err) + } + out := make([]RaceItem, 0, len(rows)) + for _, r := range rows { + // Skip anything that doesn't match the in-memory status filter + // (DB already filtered; this is a safety belt). + if !statusMatches(filters, string(r.Status)) { + continue + } + out = append(out, RaceItem{Source: "live", Meta: r}) + } + return out, nil +} + +// filterToStatusStrings returns the list of status values that the DB +// query should match. StatusAll expands to the three live statuses +// (lobby, countdown, racing). The caller adds the finished source +// separately. +func filterToStatusStrings(filters []StatusFilter) []string { + if len(filters) == 0 { + return []string{string(lobby.RaceStatusLobby), string(lobby.RaceStatusCountdown), string(lobby.RaceStatusRacing)} + } + seen := map[string]bool{} + for _, f := range filters { + switch f { + case StatusAll: + return []string{string(lobby.RaceStatusLobby), string(lobby.RaceStatusCountdown), string(lobby.RaceStatusRacing)} + case StatusRacing: + seen["racing"] = true + case StatusLobby: + seen["lobby"] = true + case StatusFinished: + // finished is served by collectFinished; skip here. + } + } + out := make([]string, 0, len(seen)) + for k := range seen { + out = append(out, k) + } + return out +} + +func (s *Service) collectFinished(ctx context.Context, filters []StatusFilter, trackID, curSource string, curMs int64, curID string, limit int) ([]RaceItem, error) { + if !statusWantsFinished(filters) { + return nil, nil + } + cur := Cursor{} + if curSource == "finished" { + cur = Cursor{Ms: curMs, ID: curID} + } + rows, err := s.pg.ListFinished(ctx, trackID, cur, limit) + if err != nil { + return nil, err + } + out := make([]RaceItem, 0, len(rows)) + for _, f := range rows { + out = append(out, RaceItem{Source: "finished", Meta: f.ToLobbyMeta(), Podium: f.Podium}) + } + return out, nil +} + +func statusMatches(filters []StatusFilter, liveStatus string) bool { + if len(filters) == 0 { + return true + } + for _, f := range filters { + if f == StatusAll { + return true + } + if f.matchesLive(liveStatus) { + return true + } + } + return false +} + +// statusWantsFinished is true if the filter list allows finished races +// through. Used to gate the Postgres query in collectFinished. +func statusWantsFinished(filters []StatusFilter) bool { + if len(filters) == 0 { + return true + } + for _, f := range filters { + if f == StatusAll || f == StatusFinished { + return true + } + } + return false +} + +// splitCursor is a no-op shim; we encode source in the id field for v1 +// simplicity (suffix ":src"). Returns ("live"|"finished", ms, id). +func splitCursor(c Cursor) (string, int64, string) { + id := c.ID + src := "live" + // Convention: id may end with ":finished" or ":live". If absent, treat as live. + for _, suf := range []string{":finished", ":live"} { + if len(id) > len(suf) && id[len(id)-len(suf):] == suf { + id = id[:len(id)-len(suf)] + src = suf[1:] + break + } + } + return src, c.Ms, id +} + +// --------------------------------------------------------------------------- +// Upcoming (next N) and queue join +// --------------------------------------------------------------------------- + +// UpcomingItem is one row in the upcoming list. +type UpcomingItem struct { + Meta lobby.RaceMeta + QueueLen int + PlanID string + StartAtMs int64 // best-effort: StartedMs for live races, otherwise CreatedMs+offset +} + +// Upcoming returns the next N open (lobby|countdown) races, soonest first. +func (s *Service) Upcoming(ctx context.Context, limit int) ([]UpcomingItem, error) { + if limit <= 0 { + limit = s.maxIDs + } + if limit > 16 { + limit = 16 + } + // When LiveStore is configured, read upcoming from Postgres. + if s.live != nil { + return s.upcomingDB(ctx, limit) + } + all := s.lobby.ListRaces() + out := make([]UpcomingItem, 0, limit) + for _, r := range all { + if r.Status != lobby.RaceStatusLobby && r.Status != lobby.RaceStatusCountdown { + continue + } + n, err := s.pg.CountQueueByRace(ctx, r.ID) + if err != nil { + return nil, err + } + startAt := r.StartedMs + if startAt == 0 { + startAt = r.CreatedMs + } + out = append(out, UpcomingItem{ + Meta: r, + QueueLen: n, + StartAtMs: startAt, + }) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].StartAtMs != out[j].StartAtMs { + return out[i].StartAtMs < out[j].StartAtMs + } + return out[i].Meta.ID < out[j].Meta.ID + }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (s *Service) upcomingDB(ctx context.Context, limit int) ([]UpcomingItem, error) { + rows, err := s.live.ListUpcoming(ctx, limit) + if err != nil { + return nil, fmt.Errorf("list upcoming: %w", err) + } + out := make([]UpcomingItem, 0, len(rows)) + for _, r := range rows { + n, err := s.pg.CountQueueByRace(ctx, r.ID) + if err != nil { + return nil, err + } + startAt := r.StartedMs + if startAt == 0 { + startAt = r.CreatedMs + } + out = append(out, UpcomingItem{ + Meta: r, + QueueLen: n, + StartAtMs: startAt, + }) + } + return out, nil +} + +// JoinUpcomingResult is what JoinUpcoming returns. +type JoinUpcomingResult struct { + Joined []QueueEntry + Skipped []string // race ids that were already at capacity +} + +// JoinUpcoming puts the driver into the queue for each of the next N +// upcoming races. Idempotent per (driver, race). +func (s *Service) JoinUpcoming(ctx context.Context, driverID string, limit int) (JoinUpcomingResult, error) { + if driverID == "" { + return JoinUpcomingResult{}, fmt.Errorf("%w: driver_id required", ErrInvalidInput) + } + up, err := s.Upcoming(ctx, limit) + if err != nil { + return JoinUpcomingResult{}, err + } + res := JoinUpcomingResult{Joined: make([]QueueEntry, 0, len(up))} + for _, u := range up { + // Skip races that are full; surfaced in Skipped for the caller. + if len(u.Meta.DriverIDs) >= u.Meta.MaxCars { + res.Skipped = append(res.Skipped, u.Meta.ID) + continue + } + // Best-effort auto-attach: if the race is still lobby and the driver + // can fit, AddDriverToRace moves them in. The queue entry remains + // for diagnostics. + if u.Meta.Status == lobby.RaceStatusLobby && len(u.Meta.DriverIDs) < u.Meta.MaxCars { + _ = s.lobby.AddDriverToRace(driverID, u.Meta.ID) + } + q, err := s.pg.Enqueue(ctx, driverID, u.Meta.ID, u.PlanID) + if err != nil { + return res, err + } + res.Joined = append(res.Joined, q) + } + return res, nil +} + +// LeaveQueue removes a single (driver, race) entry. +func (s *Service) LeaveQueue(ctx context.Context, driverID, raceID string) error { + return s.pg.Dequeue(ctx, driverID, raceID) +} + +// ListQueue returns the driver's queue entries. +func (s *Service) ListQueue(ctx context.Context, driverID string) ([]QueueEntry, error) { + return s.pg.ListQueueByDriver(ctx, driverID) +} + +// --------------------------------------------------------------------------- +// Finished-race snapshot hook +// --------------------------------------------------------------------------- + +// SnapshotFinished is the input to the "persist on finish" hook. Race is +// a copy of the in-memory meta. Result is filled by the engine. +type SnapshotFinished struct { + Meta lobby.RaceMeta + FinishedMs int64 + TotalLaps int + WinnerDriverID string + WinnerName string + BestLapMs int64 +} + +// PersistFinished upserts the finished race into Postgres. +func (s *Service) PersistFinished(ctx context.Context, sf SnapshotFinished) error { + startedMs := sf.Meta.StartedMs + finishedMs := sf.FinishedMs + if finishedMs == 0 { + finishedMs = s.now().UnixMilli() + } + r := FinishedRace{ + ID: sf.Meta.ID, + Name: sf.Meta.Name, + TrackID: sf.Meta.TrackID, + MaxCars: sf.Meta.MaxCars, + Laps: sf.Meta.Laps, + TimeLimitS: sf.Meta.TimeLimitS, + DriverIDs: append([]string(nil), sf.Meta.DriverIDs...), + Status: "finished", + CreatedMs: sf.Meta.CreatedMs, + StartedMs: startedMs, + FinishedMs: finishedMs, + DurationMs: finishedMs - startedMs, + TotalLaps: sf.TotalLaps, + TotalDrivers: len(sf.Meta.DriverIDs), + WinnerDriverID: sf.WinnerDriverID, + WinnerName: sf.WinnerName, + BestLapMs: sf.BestLapMs, + } + return s.pg.InsertFinished(ctx, r) +} + +// --------------------------------------------------------------------------- +// Race plans +// --------------------------------------------------------------------------- + +// CreatePlanInput is what the HTTP handler hands to the service. +type CreatePlanInput struct { + ID string + Name string + TrackID string + MaxCars int + Laps int + TimeLimitS int + StartAtMs int64 + IntervalS int + Count int + Enabled bool +} + +// CreatePlan validates and persists a plan. +func (s *Service) CreatePlan(ctx context.Context, in CreatePlanInput) (RacePlan, error) { + if in.Name == "" { + return RacePlan{}, fmt.Errorf("%w: name required", ErrInvalidInput) + } + if in.MaxCars < 1 || in.MaxCars > 8 { + return RacePlan{}, fmt.Errorf("%w: max_cars must be 1..8", ErrInvalidInput) + } + if in.StartAtMs <= 0 { + return RacePlan{}, fmt.Errorf("%w: start_at_ms required", ErrInvalidInput) + } + if in.IntervalS < 0 { + return RacePlan{}, fmt.Errorf("%w: interval_s must be >= 0", ErrInvalidInput) + } + if in.Count < 0 { + return RacePlan{}, fmt.Errorf("%w: count must be >= 0", ErrInvalidInput) + } + if in.ID == "" { + in.ID = fmt.Sprintf("plan-%d-%d", s.now().UnixMilli(), time.Now().UnixNano()%1000) + } + if in.TrackID == "" { + in.TrackID = "default" + } + p := RacePlan{ + ID: in.ID, + Name: in.Name, + TrackID: in.TrackID, + MaxCars: in.MaxCars, + Laps: in.Laps, + TimeLimitS: in.TimeLimitS, + StartAtMs: in.StartAtMs, + IntervalS: in.IntervalS, + Count: in.Count, + Enabled: in.Enabled, + NextFireMs: in.StartAtMs, + } + if err := s.pg.CreatePlan(ctx, p); err != nil { + return RacePlan{}, err + } + return s.pg.GetPlan(ctx, p.ID) +} + +// ListPlans returns plans in start_at ascending order. +func (s *Service) ListPlans(ctx context.Context, cur Cursor, limit int) ([]RacePlan, error) { + if limit <= 0 { + limit = 50 + } + return s.pg.ListPlans(ctx, cur, limit) +} + +// DeletePlan removes a plan by id. +func (s *Service) DeletePlan(ctx context.Context, id string) error { + return s.pg.DeletePlan(ctx, id) +} + +// --------------------------------------------------------------------------- +// Scheduler +// --------------------------------------------------------------------------- + +// Scheduler materialises due race plans into lobby races every tick. +type Scheduler struct { + pg *PgStore + lobby *lobby.Service + tick time.Duration + stopCh chan struct{} + doneCh chan struct{} + once sync.Once +} + +// NewScheduler creates a scheduler. tick default is 5s. +func NewScheduler(pg *PgStore, lb *lobby.Service, tick time.Duration) *Scheduler { + if tick <= 0 { + tick = 5 * time.Second + } + return &Scheduler{ + pg: pg, + lobby: lb, + tick: tick, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } +} + +// Run blocks until Stop is called. Safe to launch in a goroutine. +func (s *Scheduler) Run(ctx context.Context) { + defer close(s.doneCh) + t := time.NewTicker(s.tick) + defer t.Stop() + // Fire once on start so newly-enabled plans materialise promptly. + s.tickOnce(ctx) + for { + select { + case <-ctx.Done(): + return + case <-s.stopCh: + return + case <-t.C: + s.tickOnce(ctx) + } + } +} + +// Stop signals the scheduler to exit. +func (s *Scheduler) Stop() { + s.once.Do(func() { close(s.stopCh) }) +} + +// Done blocks until the scheduler goroutine has returned. +func (s *Scheduler) Done() <-chan struct{} { return s.doneCh } + +func (s *Scheduler) tickOnce(ctx context.Context) { + now := s.now().UnixMilli() + plans, err := s.pg.DuePlans(ctx, now, 32) + if err != nil { + return + } + for _, p := range plans { + meta, err := s.lobby.CreateRace(lobby.CreateRaceOptions{ + Name: p.Name, + TrackID: p.TrackID, + MaxCars: p.MaxCars, + Laps: p.Laps, + TimeLimitS: p.TimeLimitS, + }) + if err != nil { + // Could not create; advance anyway to avoid hot-loop on the + // same plan. + next := p.NextFireMs + if p.IntervalS > 0 { + next = next + int64(p.IntervalS)*1000 + } + _ = s.pg.AdvancePlan(ctx, p.ID, next) + continue + } + // Compute next fire time. + next := p.NextFireMs + if p.IntervalS > 0 { + next = p.NextFireMs + int64(p.IntervalS)*1000 + } + _ = s.advance(ctx, p, next) + // Auto-attach queued drivers to the new race (best-effort). + s.attachQueued(ctx, meta.ID) + } +} + +func (s *Scheduler) advance(ctx context.Context, p RacePlan, nextFireMs int64) error { + if nextFireMs == 0 && p.IntervalS == 0 { + // Single-shot: disable. + return s.pg.SetPlanEnabled(ctx, p.ID, false) + } + return s.pg.AdvancePlan(ctx, p.ID, nextFireMs) +} + +func (s *Scheduler) attachQueued(ctx context.Context, raceID string) { + entries, err := s.pg.ListQueueByRace(ctx, raceID) + if err != nil { + return + } + for _, q := range entries { + _, _ = s.lobby.AddDriver(q.DriverID, "", "") //nolint:errcheck // best-effort + _ = s.lobby.AddDriverToRace(q.DriverID, raceID) + _ = s.pg.Dequeue(ctx, q.DriverID, raceID) + } +} + +func (s *Scheduler) now() time.Time { return time.Now() } diff --git a/server/internal/races/store.go b/server/internal/races/store.go new file mode 100644 index 0000000..a0cbd63 --- /dev/null +++ b/server/internal/races/store.go @@ -0,0 +1,803 @@ +package races + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/x0gp/server/internal/lobby" +) + +// PodiumEntry is one row in the top-3 of a finished race. Ordered by +// Position (1..3). DriverID/Name are denormalised from the future +// drivers table; TotalTimeMs is the driver's total race time. +type PodiumEntry struct { + Position int `json:"position" example:"1"` + DriverID string `json:"driver_id" example:"driver-alice"` + Name string `json:"name" example:"driver-alice"` + TotalTimeMs int64 `json:"total_time_ms" example:"123456"` +} + +// FinishedRace is the on-disk shape of a completed race. Mirrors the +// lobby.RaceMeta plus outcome fields populated by the engine at finish. +type FinishedRace struct { + ID string + Name string + TrackID string + MaxCars int + Laps int + TimeLimitS int + DriverIDs []string + Status string // "finished" | "cancelled" + CreatedMs int64 + StartedMs int64 + FinishedMs int64 + DurationMs int64 + TotalLaps int + TotalDrivers int + WinnerDriverID string + WinnerName string + BestLapMs int64 + Podium []PodiumEntry +} + +// RacePlan is a recurring or one-off scheduled race description. +type RacePlan struct { + ID string + Name string + TrackID string + MaxCars int + Laps int + TimeLimitS int + StartAtMs int64 + IntervalS int + Count int + Enabled bool + CreatedMs int64 + UpdatedMs int64 + NextFireMs int64 + FiresDone int +} + +// QueueEntry is a single (driver, race) subscription. +type QueueEntry struct { + DriverID string + RaceID string + PlanID string + EnqueuedMs int64 +} + +// PgStore is the Postgres-backed half of the races package. +type PgStore struct { + pool *pgxpool.Pool +} + +// NewPgStore wraps an open pgxpool. +func NewPgStore(pool *pgxpool.Pool) *PgStore { + return &PgStore{pool: pool} +} + +// Exec exposes a raw Exec for the seeder / maintenance scripts. +func (s *PgStore) Exec(ctx context.Context, sql string) (int64, error) { + tag, err := s.pool.Exec(ctx, sql) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +// ListTrackIDs returns all track ids from the tracks table. Used by +// the seeder to bind race_plans/finished_races to real catalog entries. +func (s *PgStore) ListTrackIDs(ctx context.Context) ([]string, error) { + rows, err := s.pool.Query(ctx, `SELECT id FROM tracks ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("list track ids: %w", err) + } + defer rows.Close() + out := make([]string, 0) + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +// --------------------------------------------------------------------------- +// Live race surface (status in lobby | countdown | racing). +// +// The unified `races` table holds both live and finished rows. Methods +// below are the write-side mirror for lobby.Service; reads flow through +// races.Service via ListRacesPaged (status filter) and ListUpcoming. +// --------------------------------------------------------------------------- + +// UpsertLive mirrors a lobby.RaceMeta row. Only used for status in +// (lobby, countdown, racing). Status is enforced at the call site. +func (s *PgStore) UpsertLive(ctx context.Context, r lobby.RaceMeta) error { + now := time.Now().UnixMilli() + _, err := s.pool.Exec(ctx, ` + INSERT INTO races + (id, name, track_id, max_cars, laps, time_limit_s, status, + created_ms, started_ms, finished_ms, updated_ms, podium) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10, '[]'::jsonb) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + track_id = EXCLUDED.track_id, + max_cars = EXCLUDED.max_cars, + laps = EXCLUDED.laps, + time_limit_s = EXCLUDED.time_limit_s, + status = EXCLUDED.status, + started_ms = EXCLUDED.started_ms, + updated_ms = EXCLUDED.updated_ms`, + r.ID, r.Name, r.TrackID, r.MaxCars, r.Laps, r.TimeLimitS, string(r.Status), + r.CreatedMs, r.StartedMs, now) + if err != nil { + return fmt.Errorf("upsert live race %s: %w", r.ID, err) + } + return nil +} + +// DeleteLive removes a live race row. The FK from race_drivers has +// CASCADE so the related drivers are also removed. +func (s *PgStore) DeleteLive(ctx context.Context, raceID string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM races WHERE id = $1 AND status IN ('lobby','countdown','racing')`, raceID) + return err +} + +// AddLiveDriver adds a driver to a live race (slot=position). Idempotent. +func (s *PgStore) AddLiveDriver(ctx context.Context, raceID, driverID string, slot int) error { + now := time.Now().UnixMilli() + _, err := s.pool.Exec(ctx, ` + INSERT INTO race_drivers (race_id, driver_id, slot, joined_ms) + VALUES ($1, $2, $3, $4) + ON CONFLICT (race_id, driver_id) DO UPDATE SET slot = EXCLUDED.slot`, + raceID, driverID, slot, now) + if err != nil { + return fmt.Errorf("add live driver %s to %s: %w", driverID, raceID, err) + } + return nil +} + +// RemoveLiveDriver removes a driver from a live race. +func (s *PgStore) RemoveLiveDriver(ctx context.Context, raceID, driverID string) error { + _, err := s.pool.Exec(ctx, + `DELETE FROM race_drivers WHERE race_id = $1 AND driver_id = $2`, + raceID, driverID) + return err +} + +// SetLiveStatus updates the status (and started_ms) of a live race. +func (s *PgStore) SetLiveStatus(ctx context.Context, raceID string, status lobby.RaceStatus, startedMs int64) error { + now := time.Now().UnixMilli() + _, err := s.pool.Exec(ctx, ` + UPDATE races + SET status = $2, started_ms = $3, updated_ms = $4 + WHERE id = $1 AND status IN ('lobby','countdown','racing')`, + raceID, string(status), startedMs, now) + return err +} + +// ListLivePaged returns one keyset page of live races. +func (s *PgStore) ListLivePaged(ctx context.Context, statuses []string, trackID string, cur Cursor, limit int) ([]lobby.RaceMeta, error) { + if limit <= 0 { + limit = 50 + } + args := []any{} + conds := []string{"status IN ('lobby','countdown','racing')"} + if len(statuses) > 0 { + args = append(args, statuses) + conds = append(conds, fmt.Sprintf("status = ANY($%d)", len(args))) + } + if trackID != "" { + args = append(args, trackID) + conds = append(conds, fmt.Sprintf("track_id = $%d", len(args))) + } + if !cur.IsZero() { + args = append(args, cur.Ms, cur.ID) + conds = append(conds, fmt.Sprintf("(COALESCE(started_ms, created_ms), id) < ($%d, $%d)", len(args)-1, len(args))) + } + args = append(args, limit+1) + q := `SELECT id, name, track_id, max_cars, laps, time_limit_s, status, + created_ms, started_ms + FROM races + WHERE ` + joinAnd(conds) + ` + ORDER BY COALESCE(started_ms, created_ms) DESC, id ASC + LIMIT $` + fmt.Sprintf("%d", len(args)) + rows, err := s.pool.Query(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]lobby.RaceMeta, 0) + for rows.Next() { + var r lobby.RaceMeta + var status string + if err := rows.Scan(&r.ID, &r.Name, &r.TrackID, &r.MaxCars, &r.Laps, &r.TimeLimitS, + &status, &r.CreatedMs, &r.StartedMs); err != nil { + return nil, err + } + r.Status = lobby.RaceStatus(status) + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, err + } + for i := range out { + ids, err := s.listDriverIDs(ctx, out[i].ID) + if err != nil { + return nil, err + } + out[i].DriverIDs = ids + } + return out, nil +} + +// ListLiveUpcoming returns up to limit live races in (lobby, countdown) +// ordered soonest first. +func (s *PgStore) ListLiveUpcoming(ctx context.Context, limit int) ([]lobby.RaceMeta, error) { + if limit <= 0 { + limit = 3 + } + rows, err := s.pool.Query(ctx, ` + SELECT id, name, track_id, max_cars, laps, time_limit_s, status, + created_ms, started_ms + FROM races + WHERE status IN ('lobby', 'countdown') + ORDER BY COALESCE(started_ms, created_ms) ASC, id ASC + LIMIT $1`, limit+1) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]lobby.RaceMeta, 0) + for rows.Next() { + var r lobby.RaceMeta + var status string + if err := rows.Scan(&r.ID, &r.Name, &r.TrackID, &r.MaxCars, &r.Laps, &r.TimeLimitS, + &status, &r.CreatedMs, &r.StartedMs); err != nil { + return nil, err + } + r.Status = lobby.RaceStatus(status) + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, err + } + for i := range out { + ids, err := s.listDriverIDs(ctx, out[i].ID) + if err != nil { + return nil, err + } + out[i].DriverIDs = ids + } + return out, nil +} + +// GetLive fetches a single live race. +func (s *PgStore) GetLive(ctx context.Context, id string) (lobby.RaceMeta, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, name, track_id, max_cars, laps, time_limit_s, status, + created_ms, started_ms + FROM races WHERE id = $1 AND status IN ('lobby','countdown','racing')`, id) + var r lobby.RaceMeta + var status string + if err := row.Scan(&r.ID, &r.Name, &r.TrackID, &r.MaxCars, &r.Laps, &r.TimeLimitS, + &status, &r.CreatedMs, &r.StartedMs); err != nil { + return lobby.RaceMeta{}, err + } + r.Status = lobby.RaceStatus(status) + ids, err := s.listDriverIDs(ctx, id) + if err != nil { + return lobby.RaceMeta{}, err + } + r.DriverIDs = ids + return r, nil +} + +// ListAllRaces returns every row in the races table (live+finished). +// Used by RestoreFromDB to rehydrate the lobby. +func (s *PgStore) ListAllRaces(ctx context.Context) ([]lobby.RaceMeta, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, name, track_id, max_cars, laps, time_limit_s, status, + created_ms, started_ms + FROM races + WHERE status IN ('lobby','countdown','racing') + ORDER BY created_ms ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]lobby.RaceMeta, 0) + for rows.Next() { + var r lobby.RaceMeta + var status string + if err := rows.Scan(&r.ID, &r.Name, &r.TrackID, &r.MaxCars, &r.Laps, &r.TimeLimitS, + &status, &r.CreatedMs, &r.StartedMs); err != nil { + return nil, err + } + r.Status = lobby.RaceStatus(status) + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, err + } + for i := range out { + ids, err := s.listDriverIDs(ctx, out[i].ID) + if err != nil { + return nil, err + } + out[i].DriverIDs = ids + } + return out, nil +} + +func (s *PgStore) listDriverIDs(ctx context.Context, raceID string) ([]string, error) { + rows, err := s.pool.Query(ctx, + `SELECT driver_id FROM race_drivers WHERE race_id = $1 ORDER BY slot ASC, joined_ms ASC`, + raceID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]string, 0) + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +// UpsertLobbyDriver mirrors a lobby.DriverMeta row. +func (s *PgStore) UpsertLobbyDriver(ctx context.Context, d lobby.DriverMeta) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO lobby_drivers + (driver_id, name, nickname, avatar_url, clan_id, clan_tag, + status, current_race_id, connected_ms, last_seen_ms) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (driver_id) DO UPDATE SET + name = EXCLUDED.name, + nickname = EXCLUDED.nickname, + avatar_url = EXCLUDED.avatar_url, + clan_id = EXCLUDED.clan_id, + clan_tag = EXCLUDED.clan_tag, + status = EXCLUDED.status, + current_race_id = EXCLUDED.current_race_id, + last_seen_ms = EXCLUDED.last_seen_ms`, + d.ID, d.Name, d.Nickname, d.AvatarURL, d.ClanID, d.ClanTag, + string(d.Status), d.RaceID, d.ConnectedMs, d.LastSeenMs) + if err != nil { + return fmt.Errorf("upsert lobby driver %s: %w", d.ID, err) + } + return nil +} + +// DeleteLobbyDriver removes a lobby driver row. +func (s *PgStore) DeleteLobbyDriver(ctx context.Context, driverID string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM lobby_drivers WHERE driver_id = $1`, driverID) + return err +} + +// ListLobbyDrivers returns all lobby drivers. +func (s *PgStore) ListLobbyDrivers(ctx context.Context) ([]lobby.DriverMeta, error) { + rows, err := s.pool.Query(ctx, ` + SELECT driver_id, name, nickname, avatar_url, clan_id, clan_tag, + status, current_race_id, connected_ms, last_seen_ms + FROM lobby_drivers ORDER BY last_seen_ms DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]lobby.DriverMeta, 0) + for rows.Next() { + var d lobby.DriverMeta + var status string + if err := rows.Scan(&d.ID, &d.Name, &d.Nickname, &d.AvatarURL, &d.ClanID, &d.ClanTag, + &status, &d.RaceID, &d.ConnectedMs, &d.LastSeenMs); err != nil { + return nil, err + } + d.Status = lobby.DriverStatus(status) + out = append(out, d) + } + return out, rows.Err() +} + +// --------------------------------------------------------------------------- +// finished_races +// --------------------------------------------------------------------------- + +// InsertFinished upserts a finished race row. +func (s *PgStore) InsertFinished(ctx context.Context, r FinishedRace) error { + podium := r.Podium + if podium == nil { + podium = []PodiumEntry{} + } + podiumJSON, err := json.Marshal(podium) + if err != nil { + return fmt.Errorf("marshal podium: %w", err) + } + _, err = s.pool.Exec(ctx, ` + INSERT INTO races + (id, name, track_id, max_cars, laps, time_limit_s, + driver_ids, status, created_ms, started_ms, finished_ms, duration_ms, + total_laps, total_drivers, winner_driver_id, winner_name, best_lap_ms, + podium) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + status = EXCLUDED.status, + finished_ms = EXCLUDED.finished_ms, + duration_ms = EXCLUDED.duration_ms, + total_laps = EXCLUDED.total_laps, + total_drivers = EXCLUDED.total_drivers, + winner_driver_id = EXCLUDED.winner_driver_id, + winner_name = EXCLUDED.winner_name, + best_lap_ms = EXCLUDED.best_lap_ms, + podium = EXCLUDED.podium`, + r.ID, r.Name, r.TrackID, r.MaxCars, r.Laps, r.TimeLimitS, + r.DriverIDs, r.Status, r.CreatedMs, r.StartedMs, r.FinishedMs, r.DurationMs, + r.TotalLaps, r.TotalDrivers, r.WinnerDriverID, r.WinnerName, r.BestLapMs, + podiumJSON) + if err != nil { + return fmt.Errorf("insert finished race %s: %w", r.ID, err) + } + return nil +} + +// ListFinished returns finished races matching the filter using keyset +// pagination on (finished_ms DESC, id DESC). The cursor's Ms is the +// last finished_ms from the previous page; rows strictly older are +// returned. Tie-breaker on id. +func (s *PgStore) ListFinished(ctx context.Context, trackID string, cur Cursor, limit int) ([]FinishedRace, error) { + if limit <= 0 { + limit = 50 + } + args := []any{} + conds := []string{"status IN ('finished','cancelled')"} + if !cur.IsZero() { + args = append(args, cur.Ms, cur.ID) + conds = append(conds, fmt.Sprintf("(finished_ms, id) < ($%d, $%d)", len(args)-1, len(args))) + } + if trackID != "" { + args = append(args, trackID) + conds = append(conds, fmt.Sprintf("track_id = $%d", len(args))) + } + args = append(args, limit+1) + q := `SELECT id, name, track_id, max_cars, laps, time_limit_s, + driver_ids, status, created_ms, started_ms, finished_ms, duration_ms, + total_laps, total_drivers, winner_driver_id, winner_name, best_lap_ms, + podium + FROM races + WHERE ` + joinAnd(conds) + ` + ORDER BY finished_ms DESC, id DESC + LIMIT $` + fmt.Sprintf("%d", len(args)) + rows, err := s.pool.Query(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("list finished: %w", err) + } + defer rows.Close() + return scanFinished(rows) +} + +func scanFinished(rows pgx.Rows) ([]FinishedRace, error) { + out := make([]FinishedRace, 0) + for rows.Next() { + var r FinishedRace + var podiumJSON []byte + if err := rows.Scan( + &r.ID, &r.Name, &r.TrackID, + &r.MaxCars, &r.Laps, &r.TimeLimitS, &r.DriverIDs, &r.Status, + &r.CreatedMs, &r.StartedMs, &r.FinishedMs, &r.DurationMs, + &r.TotalLaps, &r.TotalDrivers, &r.WinnerDriverID, &r.WinnerName, &r.BestLapMs, + &podiumJSON, + ); err != nil { + return nil, err + } + if len(podiumJSON) > 0 { + if err := json.Unmarshal(podiumJSON, &r.Podium); err != nil { + return nil, fmt.Errorf("unmarshal podium: %w", err) + } + } + if r.Podium == nil { + r.Podium = []PodiumEntry{} + } + out = append(out, r) + } + return out, rows.Err() +} + +func joinAnd(parts []string) string { + out := "" + for i, p := range parts { + if i > 0 { + out += " AND " + } + out += p + } + return out +} + +// --------------------------------------------------------------------------- +// race_plans +// --------------------------------------------------------------------------- + +// CreatePlan inserts a new race plan. +func (s *PgStore) CreatePlan(ctx context.Context, p RacePlan) error { + now := time.Now().UnixMilli() + if p.CreatedMs == 0 { + p.CreatedMs = now + } + p.UpdatedMs = now + if p.NextFireMs == 0 { + p.NextFireMs = p.StartAtMs + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO race_plans + (id, name, track_id, max_cars, laps, time_limit_s, + start_at_ms, interval_s, count, + enabled, created_ms, updated_ms, next_fire_ms, fires_done) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + p.ID, p.Name, p.TrackID, p.MaxCars, p.Laps, p.TimeLimitS, + p.StartAtMs, p.IntervalS, p.Count, + p.Enabled, p.CreatedMs, p.UpdatedMs, p.NextFireMs, p.FiresDone) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return fmt.Errorf("%w: plan %s", ErrAlreadyExists, p.ID) + } + return fmt.Errorf("insert plan: %w", err) + } + return nil +} + +// GetPlan loads a plan by id. +func (s *PgStore) GetPlan(ctx context.Context, id string) (RacePlan, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, name, track_id, max_cars, laps, time_limit_s, + start_at_ms, interval_s, count, + enabled, created_ms, updated_ms, next_fire_ms, fires_done + FROM race_plans WHERE id = $1`, id) + var p RacePlan + if err := row.Scan( + &p.ID, &p.Name, &p.TrackID, &p.MaxCars, &p.Laps, &p.TimeLimitS, + &p.StartAtMs, &p.IntervalS, &p.Count, + &p.Enabled, &p.CreatedMs, &p.UpdatedMs, &p.NextFireMs, &p.FiresDone, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return RacePlan{}, ErrNotFound + } + return RacePlan{}, err + } + return p, nil +} + +// ListPlans returns plans in ascending start order, keyset-paginated. +func (s *PgStore) ListPlans(ctx context.Context, cur Cursor, limit int) ([]RacePlan, error) { + if limit <= 0 { + limit = 50 + } + args := []any{} + conds := []string{"1=1"} + if !cur.IsZero() { + args = append(args, cur.Ms, cur.ID) + conds = append(conds, fmt.Sprintf("(start_at_ms, id) > ($%d, $%d)", len(args)-1, len(args))) + } + args = append(args, limit+1) + q := `SELECT id, name, track_id, max_cars, laps, time_limit_s, + start_at_ms, interval_s, count, + enabled, created_ms, updated_ms, next_fire_ms, fires_done + FROM race_plans + WHERE ` + joinAnd(conds) + ` + ORDER BY start_at_ms ASC, id ASC + LIMIT $` + fmt.Sprintf("%d", len(args)) + rows, err := s.pool.Query(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("list plans: %w", err) + } + defer rows.Close() + return scanPlans(rows) +} + +func scanPlans(rows pgx.Rows) ([]RacePlan, error) { + out := make([]RacePlan, 0) + for rows.Next() { + var p RacePlan + if err := rows.Scan( + &p.ID, &p.Name, &p.TrackID, &p.MaxCars, &p.Laps, &p.TimeLimitS, + &p.StartAtMs, &p.IntervalS, &p.Count, + &p.Enabled, &p.CreatedMs, &p.UpdatedMs, &p.NextFireMs, &p.FiresDone, + ); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// DeletePlan removes a plan by id. +func (s *PgStore) DeletePlan(ctx context.Context, id string) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM race_plans WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("delete plan: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// SetPlanEnabled toggles the enabled flag. +func (s *PgStore) SetPlanEnabled(ctx context.Context, id string, enabled bool) error { + tag, err := s.pool.Exec(ctx, + `UPDATE race_plans SET enabled = $2, updated_ms = $3 WHERE id = $1`, + id, enabled, time.Now().UnixMilli()) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// AdvancePlan moves the next_fire_ms cursor after a successful materialise +// and increments fires_done. If count is reached, disables the plan. +func (s *PgStore) AdvancePlan(ctx context.Context, id string, nextFireMs int64) error { + _, err := s.pool.Exec(ctx, ` + UPDATE race_plans + SET next_fire_ms = $2, + fires_done = fires_done + 1, + updated_ms = $3, + enabled = CASE + WHEN count > 0 AND fires_done + 1 >= count THEN FALSE + ELSE enabled + END + WHERE id = $1`, + id, nextFireMs, time.Now().UnixMilli()) + return err +} + +// DuePlans returns plans whose next_fire_ms is <= now. +func (s *PgStore) DuePlans(ctx context.Context, nowMs int64, limit int) ([]RacePlan, error) { + if limit <= 0 { + limit = 16 + } + rows, err := s.pool.Query(ctx, ` + SELECT id, name, track_id, max_cars, laps, time_limit_s, + start_at_ms, interval_s, count, + enabled, created_ms, updated_ms, next_fire_ms, fires_done + FROM race_plans + WHERE enabled AND next_fire_ms <= $1 + ORDER BY next_fire_ms ASC + LIMIT $2`, nowMs, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanPlans(rows) +} + +// --------------------------------------------------------------------------- +// queue +// --------------------------------------------------------------------------- + +// Enqueue adds (driver, race) to the queue. Idempotent: returns the existing +// entry if present. +func (s *PgStore) Enqueue(ctx context.Context, driverID, raceID, planID string) (QueueEntry, error) { + if driverID == "" || raceID == "" { + return QueueEntry{}, fmt.Errorf("%w: driver_id and race_id required", ErrInvalidInput) + } + now := time.Now().UnixMilli() + _, err := s.pool.Exec(ctx, ` + INSERT INTO race_queue (driver_id, race_id, plan_id, enqueued_ms) + VALUES ($1, $2, $3, $4) + ON CONFLICT (driver_id, race_id) DO NOTHING`, + driverID, raceID, planID, now) + if err != nil { + return QueueEntry{}, fmt.Errorf("enqueue: %w", err) + } + row := s.pool.QueryRow(ctx, ` + SELECT driver_id, race_id, plan_id, enqueued_ms + FROM race_queue WHERE driver_id = $1 AND race_id = $2`, + driverID, raceID) + var q QueueEntry + if err := row.Scan(&q.DriverID, &q.RaceID, &q.PlanID, &q.EnqueuedMs); err != nil { + return QueueEntry{}, err + } + return q, nil +} + +// Dequeue removes (driver, race) entry. +func (s *PgStore) Dequeue(ctx context.Context, driverID, raceID string) error { + tag, err := s.pool.Exec(ctx, + `DELETE FROM race_queue WHERE driver_id = $1 AND race_id = $2`, + driverID, raceID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// ListQueueByDriver returns queue entries for a driver. +func (s *PgStore) ListQueueByDriver(ctx context.Context, driverID string) ([]QueueEntry, error) { + rows, err := s.pool.Query(ctx, ` + SELECT driver_id, race_id, plan_id, enqueued_ms + FROM race_queue WHERE driver_id = $1 + ORDER BY enqueued_ms ASC`, driverID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]QueueEntry, 0) + for rows.Next() { + var q QueueEntry + if err := rows.Scan(&q.DriverID, &q.RaceID, &q.PlanID, &q.EnqueuedMs); err != nil { + return nil, err + } + out = append(out, q) + } + return out, rows.Err() +} + +// ListQueueByRace returns all queue entries for a given race, oldest first. +func (s *PgStore) ListQueueByRace(ctx context.Context, raceID string) ([]QueueEntry, error) { + rows, err := s.pool.Query(ctx, ` + SELECT driver_id, race_id, plan_id, enqueued_ms + FROM race_queue WHERE race_id = $1 + ORDER BY enqueued_ms ASC`, raceID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]QueueEntry, 0) + for rows.Next() { + var q QueueEntry + if err := rows.Scan(&q.DriverID, &q.RaceID, &q.PlanID, &q.EnqueuedMs); err != nil { + return nil, err + } + out = append(out, q) + } + return out, rows.Err() +} + +// CountQueueByRace returns the queue length for a race. +func (s *PgStore) CountQueueByRace(ctx context.Context, raceID string) (int, error) { + var n int + row := s.pool.QueryRow(ctx, + `SELECT COUNT(*) FROM race_queue WHERE race_id = $1`, raceID) + if err := row.Scan(&n); err != nil { + return 0, err + } + return n, nil +} + +// ToLobbyMeta converts a finished race to a wire-friendly RaceMeta for +// uniform presentation alongside live races. Podium is dropped here; +// the wire type carries the full PodiumEntry list separately. +func (f FinishedRace) ToLobbyMeta() lobby.RaceMeta { + return lobby.RaceMeta{ + ID: f.ID, + Name: f.Name, + TrackID: f.TrackID, + MaxCars: f.MaxCars, + Laps: f.Laps, + TimeLimitS: f.TimeLimitS, + DriverIDs: append([]string(nil), f.DriverIDs...), + Status: lobby.RaceStatus(f.Status), + CreatedMs: f.CreatedMs, + StartedMs: f.StartedMs, + } +} diff --git a/server/internal/races/types.go b/server/internal/races/types.go new file mode 100644 index 0000000..00579ca --- /dev/null +++ b/server/internal/races/types.go @@ -0,0 +1,118 @@ +// 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). +package races + +import ( + "errors" + "fmt" +) + +// Errors returned by the service. +var ( + ErrNotFound = errors.New("race or plan not found") + ErrInvalidInput = errors.New("invalid input") + ErrAlreadyExists = errors.New("already exists") + ErrQueueDuplicate = errors.New("driver already in queue for this race") +) + +// StatusFilter enumerates the high-level status groups the API understands. +type StatusFilter string + +const ( + StatusAll StatusFilter = "all" + StatusFinished StatusFilter = "finished" // finished only + StatusRacing StatusFilter = "racing" // racing only + StatusLobby StatusFilter = "lobby" // lobby only +) + +// ParseStatusFilter accepts a comma-separated list of statuses +// (e.g. "racing,finished") and returns the canonical filters. Empty +// input means StatusAll. +func ParseStatusFilter(s string) ([]StatusFilter, error) { + if s == "" { + return []StatusFilter{StatusAll}, nil + } + out := make([]StatusFilter, 0, 2) + cur := "" + for i := 0; i < len(s); i++ { + c := s[i] + if c == ',' { + tok := statusFilterTrim(cur) + if tok == "" { + cur = "" + continue + } + f, err := parseSingleStatus(tok) + if err != nil { + return nil, err + } + out = append(out, f) + cur = "" + continue + } + cur += string(c) + } + tok := statusFilterTrim(cur) + if tok != "" { + f, err := parseSingleStatus(tok) + if err != nil { + return nil, err + } + out = append(out, f) + } + if len(out) == 0 { + return []StatusFilter{StatusAll}, nil + } + return out, nil +} + +func statusFilterTrim(s string) string { + // Trim spaces. + for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') { + s = s[1:] + } + for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') { + s = s[:len(s)-1] + } + return s +} + +func parseSingleStatus(s string) (StatusFilter, error) { + switch s { + case "all": + return StatusAll, nil + case "finished": + return StatusFinished, nil + case "racing": + return StatusRacing, nil + case "lobby": + return StatusLobby, nil + default: + return "", fmt.Errorf("%w: unknown status %q", ErrInvalidInput, s) + } +} + +// matches returns true if the live lobby status passes the filter. +func (f StatusFilter) matchesLive(liveStatus string) bool { + switch f { + case StatusAll: + return true + case StatusRacing: + return liveStatus == "racing" + case StatusLobby: + return liveStatus == "lobby" + } + return false +} diff --git a/server/internal/realtime/hub.go b/server/internal/realtime/hub.go new file mode 100644 index 0000000..0db9b03 --- /dev/null +++ b/server/internal/realtime/hub.go @@ -0,0 +1,168 @@ +// Package realtime manages WebSocket connections: registration, fan-out of +// race snapshots, and per-client send queues with backpressure handling. +package realtime + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "github.com/x0gp/server/internal/transport" +) + +// Client is a single connected WebSocket client. +type Client struct { + ID string + Send chan []byte + SessionID string + Done chan struct{} + once sync.Once +} + +// Close signals the read/write pumps to exit and frees the send channel. +func (c *Client) Close() { + c.once.Do(func() { close(c.Done) }) +} + +// Hub multiplexes snapshot broadcast to many clients. +type Hub struct { + mu sync.RWMutex + clients map[*Client]struct{} + register chan *Client + unregister chan *Client + broadcast chan []byte + stopCh chan struct{} + doneCh chan struct{} + + // Stats. + connTotal atomic.Uint64 + dropTotal atomic.Uint64 +} + +// NewHub creates a hub with sane defaults (send-buffer 64 frames). +func NewHub() *Hub { + return &Hub{ + clients: make(map[*Client]struct{}), + register: make(chan *Client, 16), + unregister: make(chan *Client, 16), + broadcast: make(chan []byte, 64), + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } +} + +// Stats returns current hub statistics. +type Stats struct { + Connections uint64 + DropsTotal uint64 +} + +func (h *Hub) Stats() Stats { + h.mu.RLock() + n := uint64(len(h.clients)) + h.mu.RUnlock() + return Stats{Connections: n, DropsTotal: h.dropTotal.Load()} +} + +// Register adds a client to the hub. +func (h *Hub) Register(c *Client) { h.register <- c } + +// Unregister removes a client from the hub. +func (h *Hub) Unregister(c *Client) { + select { + case h.unregister <- c: + default: + // Channel full; force-close anyway. + c.Close() + } +} + +// Run drives the hub until ctx is cancelled or Stop() is called. +func (h *Hub) Run(ctx context.Context) { + defer close(h.doneCh) + + // Snapshot fan-out loop. + go h.snapshotFanout(ctx) + + for { + select { + case <-ctx.Done(): + return + case <-h.stopCh: + return + case c := <-h.register: + h.mu.Lock() + h.clients[c] = struct{}{} + h.connTotal.Add(1) + h.mu.Unlock() + case c := <-h.unregister: + h.mu.Lock() + if _, ok := h.clients[c]; ok { + delete(h.clients, c) + close(c.Send) + } + h.mu.Unlock() + } + } +} + +// Stop terminates the hub. +func (h *Hub) Stop() { + close(h.stopCh) + <-h.doneCh +} + +// snapshotFanout consumes from the broadcast channel and sends to clients, +// dropping frames to slow consumers. +func (h *Hub) snapshotFanout(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case <-h.stopCh: + return + case frame, ok := <-h.broadcast: + if !ok { + return + } + h.fanout(frame) + } + } +} + +// fanout delivers a frame to every client (non-blocking per-client). +func (h *Hub) fanout(frame []byte) { + h.mu.RLock() + defer h.mu.RUnlock() + for c := range h.clients { + select { + case c.Send <- frame: + default: + // Slow consumer; drop and count. + h.dropTotal.Add(1) + } + } +} + +// Publish enqueues an envelope to be broadcast to all clients. +func (h *Hub) Publish(env *transport.Envelope) { + data, err := transport.Encode(env) + if err != nil { + return + } + select { + case h.broadcast <- data: + default: + h.dropTotal.Add(1) + } +} + +// helper to construct a server -> client envelope. +func MustEnvelope(t transport.MessageType, payload any) *transport.Envelope { + return &transport.Envelope{ + Type: t, + TSMs: time.Now().UnixMilli(), + Payload: payload, + } +} diff --git a/server/internal/stats/stats_test.go b/server/internal/stats/stats_test.go new file mode 100644 index 0000000..12f59a1 --- /dev/null +++ b/server/internal/stats/stats_test.go @@ -0,0 +1,109 @@ +package stats + +import ( + "testing" + "time" +) + +func TestConnectDisconnectCounts(t *testing.T) { + c := NewCollector() + c.Start() + c.OnConnect("d1", "Alice") + c.OnConnect("d2", "Bob") + c.OnDisconnect("d1", 5000) + + s := c.Server() + if s.CurrentClients != 1 { + t.Errorf("CurrentClients: got %d", s.CurrentClients) + } + if s.PeakClients != 2 { + t.Errorf("PeakClients: got %d", s.PeakClients) + } + if s.TotalConnections != 2 { + t.Errorf("TotalConnections: got %d", s.TotalConnections) + } + if s.TotalDisconnects != 1 { + t.Errorf("TotalDisconnects: got %d", s.TotalDisconnects) + } + if s.UptimeMs < 0 { + t.Errorf("UptimeMs negative: %d", s.UptimeMs) + } +} + +func TestLapAndDriverProfile(t *testing.T) { + c := NewCollector() + c.Start() + c.OnConnect("d1", "Alice") + c.OnLapRecorded(LapRecord{RaceID: "r1", DriverID: "d1", LapMs: 12_345, TSMs: time.Now().UnixMilli()}) + c.OnLapRecorded(LapRecord{RaceID: "r1", DriverID: "d1", LapMs: 11_500, TSMs: time.Now().UnixMilli()}) + c.OnLapRecorded(LapRecord{RaceID: "r1", DriverID: "d1", LapMs: 13_000, TSMs: time.Now().UnixMilli()}) + + d := c.Driver("d1", "Alice") + if d.TotalLaps != 3 { + t.Errorf("TotalLaps: got %d", d.TotalLaps) + } + if d.BestLapMs != 11_500 { + t.Errorf("BestLapMs: got %d", d.BestLapMs) + } + if d.LastLapMs != 13_000 { + t.Errorf("LastLapMs: got %d", d.LastLapMs) + } +} + +func TestRaceFinishedUpdatesProfile(t *testing.T) { + c := NewCollector() + c.Start() + c.OnConnect("d1", "Alice") + c.OnConnect("d2", "Bob") + c.OnRaceStarted([]string{"d1", "d2"}) + + c.OnRaceFinished(RaceResult{RaceID: "r1", DriverID: "d1", Position: 1, TotalLaps: 5, BestLapMs: 11_000, TotalTimeMs: 60_000}) + c.OnRaceFinished(RaceResult{RaceID: "r1", DriverID: "d2", Position: 2, TotalLaps: 5, BestLapMs: 11_500, TotalTimeMs: 60_500}) + + d1 := c.Driver("d1", "") + if d1.Wins != 1 { + t.Errorf("Wins: got %d", d1.Wins) + } + if d1.BestLapMs != 11_000 { + t.Errorf("BestLapMs: got %d", d1.BestLapMs) + } + d2 := c.Driver("d2", "") + if d2.Wins != 0 || d2.Podiums != 1 { + t.Errorf("Podiums: got %d", d2.Podiums) + } + + s := c.Server() + if s.TotalRacesStarted != 1 || s.TotalRacesFinished != 2 { + t.Errorf("counters: started=%d finished=%d", s.TotalRacesStarted, s.TotalRacesFinished) + } +} + +func TestRTTWindowAverage(t *testing.T) { + c := NewCollector() + c.Start() + for _, v := range []int64{10, 20, 30, 40, 50} { + c.OnRTT(v) + } + s := c.Server() + if s.AvgRTTMs != 30 { + t.Errorf("AvgRTTMs: got %v want 30", s.AvgRTTMs) + } + if len(s.RecentRTTs) != 5 { + t.Errorf("RecentRTTs len: got %d", len(s.RecentRTTs)) + } +} + +func TestRecentLapsOrder(t *testing.T) { + c := NewCollector() + c.Start() + for i := 0; i < 5; i++ { + c.OnLapRecorded(LapRecord{RaceID: "r1", DriverID: "d1", LapMs: int64(10_000 + i), TSMs: int64(i)}) + } + laps := c.RecentLaps(3) + if len(laps) != 3 { + t.Fatalf("got %d laps", len(laps)) + } + if laps[0].TSMs < laps[1].TSMs { + t.Errorf("expected newest first, got order: %v", laps) + } +} \ No newline at end of file diff --git a/server/internal/stats/types.go b/server/internal/stats/types.go new file mode 100644 index 0000000..2f1738e --- /dev/null +++ b/server/internal/stats/types.go @@ -0,0 +1,363 @@ +// 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 +package stats + +import ( + "sync" + "sync/atomic" + "time" +) + +// ServerStats is the top-level metrics snapshot. +type ServerStats struct { + StartedMs int64 `json:"started_ms"` + UptimeMs int64 `json:"uptime_ms"` + CurrentClients int64 `json:"current_clients"` + PeakClients int64 `json:"peak_clients"` + TotalConnections uint64 `json:"total_connections"` + TotalDisconnects uint64 `json:"total_disconnects"` + TotalRacesCreated uint64 `json:"total_races_created"` + TotalRacesStarted uint64 `json:"total_races_started"` + TotalRacesFinished uint64 `json:"total_races_finished"` + TotalLapsRecorded uint64 `json:"total_laps_recorded"` + TotalInputsRecv uint64 `json:"total_inputs_received"` + TotalSnapshotsSent uint64 `json:"total_snapshots_sent"` + SnapshotsDropped uint64 `json:"snapshots_dropped"` + AvgRTTMs float64 `json:"avg_rtt_ms"` + RecentRTTs []int64 `json:"recent_rtt_ms"` // windowed samples +} + +// DriverProfile aggregates per-driver statistics. +type DriverProfile struct { + ID string `json:"id"` + Name string `json:"name"` + TotalRacesStarted uint64 `json:"total_races_started"` + TotalRacesFinished uint64 `json:"total_races_finished"` + TotalLaps uint64 `json:"total_laps"` + BestLapMs int64 `json:"best_lap_ms"` + LastLapMs int64 `json:"last_lap_ms"` + TotalDistanceM float64 `json:"total_distance_m"` + TotalPlaytimeMs int64 `json:"total_playtime_ms"` + Wins uint64 `json:"wins"` + Podiums uint64 `json:"podiums"` + LastSeenMs int64 `json:"last_seen_ms"` +} + +// LapRecord is one lap, kept for /stats/recent. +type LapRecord struct { + RaceID string `json:"race_id"` + DriverID string `json:"driver_id"` + LapMs int64 `json:"lap_ms"` + Sector1Ms int64 `json:"sector1_ms"` + Sector2Ms int64 `json:"sector2_ms"` + Sector3Ms int64 `json:"sector3_ms"` + Position int `json:"position,omitempty"` + TSMs int64 `json:"ts_ms"` +} + +// RaceResult is the final classification of a race for one driver. +type RaceResult struct { + RaceID string `json:"race_id"` + DriverID string `json:"driver_id"` + FinishedAtMs int64 `json:"finished_at_ms"` + Position int `json:"position"` + TotalLaps int `json:"total_laps"` + BestLapMs int64 `json:"best_lap_ms"` + TotalTimeMs int64 `json:"total_time_ms"` + DNF bool `json:"dnf"` +} + +// collector owns the runtime state. +type collector struct { + startedMs atomic.Int64 + + currentClients atomic.Int64 + peakClients atomic.Int64 + + totalConn atomic.Uint64 + totalDisc atomic.Uint64 + totalRacesC atomic.Uint64 + totalRacesS atomic.Uint64 + totalRacesF atomic.Uint64 + totalLaps atomic.Uint64 + totalInputs atomic.Uint64 + totalSnapsSent atomic.Uint64 + totalSnapDrops atomic.Uint64 + + // RTT moving window (last 32 samples). + rttMu sync.Mutex + rttWin []int64 +} + +// Collector is the public stats service. +type Collector struct { + c collector + + mu sync.RWMutex + drivers map[string]*DriverProfile + laps []LapRecord // ring buffer + lapsHead int + lapsCap int + + results []RaceResult + resultsMax int +} + +// NewCollector returns a fresh collector. +func NewCollector() *Collector { + return &Collector{ + c: collector{}, + drivers: make(map[string]*DriverProfile), + lapsCap: 200, + laps: make([]LapRecord, 200), + resultsMax: 500, + results: make([]RaceResult, 0, 500), + } +} + +// Start marks process start time. Call once. +func (c *Collector) Start() { + c.c.startedMs.Store(time.Now().UnixMilli()) +} + +// Lifecycle hooks -------------------------------------------------------- + +// OnConnect increments counters and starts a session. +func (c *Collector) OnConnect(clientID, name string) { + c.c.totalConn.Add(1) + cur := c.c.currentClients.Add(1) + for { + peak := c.c.peakClients.Load() + if cur <= peak || c.c.peakClients.CompareAndSwap(peak, cur) { + break + } + } + c.touchDriver(clientID, name) +} + +// OnDisconnect decrements current and adds session duration. +func (c *Collector) OnDisconnect(clientID string, sessionMs int64) { + c.c.currentClients.Add(-1) + c.c.totalDisc.Add(1) + c.mu.Lock() + if d, ok := c.drivers[clientID]; ok { + d.TotalPlaytimeMs += sessionMs + d.LastSeenMs = time.Now().UnixMilli() + } + c.mu.Unlock() +} + +// OnRaceCreated increments a counter. +func (c *Collector) OnRaceCreated() { c.c.totalRacesC.Add(1) } + +// OnRaceStarted increments a counter and bumps driver race count. +func (c *Collector) OnRaceStarted(driverIDs []string) { + c.c.totalRacesS.Add(1) + c.mu.Lock() + for _, id := range driverIDs { + if d, ok := c.drivers[id]; ok { + d.TotalRacesStarted++ + } + } + c.mu.Unlock() +} + +// OnRaceFinished records final classification. Position is 1-based. +func (c *Collector) OnRaceFinished(result RaceResult) { + c.c.totalRacesF.Add(1) + c.mu.Lock() + defer c.mu.Unlock() + if d, ok := c.drivers[result.DriverID]; ok { + if !result.DNF { + d.TotalRacesFinished++ + } + if result.Position == 1 { + d.Wins++ + } else if result.Position >= 1 && result.Position <= 3 { + d.Podiums++ + } + if result.BestLapMs > 0 && (d.BestLapMs == 0 || result.BestLapMs < d.BestLapMs) { + d.BestLapMs = result.BestLapMs + } + } + c.results = append(c.results, result) + if len(c.results) > c.resultsMax { + // drop oldest 10% + drop := c.resultsMax / 10 + c.results = c.results[drop:] + } +} + +// OnLapRecorded stores lap in ring buffer and updates driver totals. +func (c *Collector) OnLapRecorded(rec LapRecord) { + c.c.totalLaps.Add(1) + c.mu.Lock() + defer c.mu.Unlock() + if d, ok := c.drivers[rec.DriverID]; ok { + d.TotalLaps++ + d.LastLapMs = rec.LapMs + if rec.LapMs > 0 && (d.BestLapMs == 0 || rec.LapMs < d.BestLapMs) { + d.BestLapMs = rec.LapMs + } + } + c.laps[c.lapsHead] = rec + c.lapsHead = (c.lapsHead + 1) % c.lapsCap +} + +// OnInputProcessed increments input counter. +func (c *Collector) OnInputProcessed() { c.c.totalInputs.Add(1) } + +// OnSnapshotSent increments snapshot counter and the drop counter. +func (c *Collector) OnSnapshotSent(sent, dropped uint64) { + c.c.totalSnapsSent.Add(sent) + c.c.totalSnapDrops.Add(dropped) +} + +// OnRTT adds a sample to the moving window. +func (c *Collector) OnRTT(rttMs int64) { + if rttMs < 0 { + return + } + c.c.rttMu.Lock() + defer c.c.rttMu.Unlock() + c.c.rttWin = append(c.c.rttWin, rttMs) + if len(c.c.rttWin) > 32 { + c.c.rttWin = c.c.rttWin[len(c.c.rttWin)-32:] + } +} + +// OnDistance accumulates meters driven (called per snapshot). +func (c *Collector) OnDistance(driverID string, meters float64) { + if meters <= 0 { + return + } + c.mu.Lock() + if d, ok := c.drivers[driverID]; ok { + d.TotalDistanceM += meters + } + c.mu.Unlock() +} + +// Snapshots --------------------------------------------------------------- + +// Server returns the current server-level metrics. +func (c *Collector) Server() ServerStats { + now := time.Now().UnixMilli() + c.c.rttMu.Lock() + window := append([]int64(nil), c.c.rttWin...) + c.c.rttMu.Unlock() + + var avg float64 + if len(window) > 0 { + var sum int64 + for _, v := range window { + sum += v + } + avg = float64(sum) / float64(len(window)) + } + + return ServerStats{ + StartedMs: c.c.startedMs.Load(), + UptimeMs: now - c.c.startedMs.Load(), + CurrentClients: c.c.currentClients.Load(), + PeakClients: c.c.peakClients.Load(), + TotalConnections: c.c.totalConn.Load(), + TotalDisconnects: c.c.totalDisc.Load(), + TotalRacesCreated: c.c.totalRacesC.Load(), + TotalRacesStarted: c.c.totalRacesS.Load(), + TotalRacesFinished: c.c.totalRacesF.Load(), + TotalLapsRecorded: c.c.totalLaps.Load(), + TotalInputsRecv: c.c.totalInputs.Load(), + TotalSnapshotsSent: c.c.totalSnapsSent.Load(), + SnapshotsDropped: c.c.totalSnapDrops.Load(), + AvgRTTMs: avg, + RecentRTTs: window, + } +} + +// Driver returns the profile for one driver, creating it if missing. +func (c *Collector) Driver(id, name string) DriverProfile { + c.mu.Lock() + defer c.mu.Unlock() + d, ok := c.drivers[id] + if !ok { + d = &DriverProfile{ID: id, Name: name} + c.drivers[id] = d + } else if name != "" { + d.Name = name + } + return *d +} + +// Drivers returns a copy of all driver profiles. +func (c *Collector) Drivers() []DriverProfile { + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]DriverProfile, 0, len(c.drivers)) + for _, d := range c.drivers { + out = append(out, *d) + } + return out +} + +// RecentLaps returns the last N lap records (newest first). +func (c *Collector) RecentLaps(n int) []LapRecord { + if n <= 0 || n > c.lapsCap { + n = c.lapsCap + } + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]LapRecord, 0, n) + // Walk backwards from lapsHead. + for i := 0; i < c.lapsCap && len(out) < n; i++ { + idx := (c.lapsHead - 1 - i + c.lapsCap) % c.lapsCap + if c.laps[idx].TSMs == 0 { + continue + } + out = append(out, c.laps[idx]) + } + return out +} + +// RecentResults returns the last N race results. +func (c *Collector) RecentResults(n int) []RaceResult { + c.mu.RLock() + defer c.mu.RUnlock() + if n <= 0 || n > len(c.results) { + n = len(c.results) + } + out := make([]RaceResult, n) + copy(out, c.results[len(c.results)-n:]) + // reverse for newest-first + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return out +} + +// helpers ---------------------------------------------------------------- + +// touchDriver creates a profile if missing and updates LastSeenMs. +func (c *Collector) touchDriver(id, name string) { + c.mu.Lock() + defer c.mu.Unlock() + d, ok := c.drivers[id] + if !ok { + c.drivers[id] = &DriverProfile{ID: id, Name: name, LastSeenMs: time.Now().UnixMilli()} + return + } + d.LastSeenMs = time.Now().UnixMilli() + if name != "" && d.Name == "" { + d.Name = name + } +} \ No newline at end of file diff --git a/server/internal/storage/postgres/migrations/001_init.sql b/server/internal/storage/postgres/migrations/001_init.sql new file mode 100644 index 0000000..8fea550 --- /dev/null +++ b/server/internal/storage/postgres/migrations/001_init.sql @@ -0,0 +1,116 @@ +-- 001_init.sql — initial schema for x0gp catalog. +-- +-- Tables: +-- tracks static track definitions (oval, figure-eight, etc.) +-- track_waypoints racing line samples (1-to-many with tracks) +-- track_tags many-to-many tags for tracks +-- cars car definitions (chassis, motor, battery, etc.) +-- +-- TimescaleDB extension is enabled opportunistically — not required for the +-- catalog itself; the telemetry hypertable will be added in a later phase +-- (S2). We enable it here so that the extension is provisioned alongside +-- the base schema. + +CREATE EXTENSION IF NOT EXISTS timescaledb; + +-- --------------------------------------------------------------------------- +-- Tracks +-- --------------------------------------------------------------------------- + +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 +); + +CREATE INDEX IF NOT EXISTS cars_visibility_idx ON cars (visibility); +CREATE INDEX IF NOT EXISTS cars_owner_idx ON cars (owner_id); \ No newline at end of file diff --git a/server/internal/storage/postgres/migrations/002_seed.sql b/server/internal/storage/postgres/migrations/002_seed.sql new file mode 100644 index 0000000..12040a9 --- /dev/null +++ b/server/internal/storage/postgres/migrations/002_seed.sql @@ -0,0 +1,561 @@ +-- 002_seed.sql — initial system catalog (tracks + cars). +-- Generated from internal/catalog/seeddefs via scripts/genseed. +-- Idempotent: safe to re-apply (ON CONFLICT DO NOTHING). + +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, + 3.5500000000000003, 1.1, 0.18, 'tile', 0, 'Verstappen', + 0.4, 1.4, 3.95, 2.5, + 1700000000000, 1700000000000) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 0, 0.4, 2.2, 0, 3.2, 0) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 1, 1.1, 2.2, 0, 3, 0) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 2, 1.5, 2.05, -0.4, 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 ('monaco', 3, 1.7, 1.85, -0.9, 2.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 ('monaco', 4, 1.55, 1.55, -1.6, 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 ('monaco', 5, 1.25, 1.4, -2.4, 2.1, 1.2) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 6, 0.95, 1.55, 3.141592653589793, 1.9, 1.6) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 7, 0.75, 1.85, 2, 1.8, 2.2) +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.95, 2.15, 2.6, 1.8, 2.6) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 9, 1.2, 2.4, -2.8, 2.1, 1.4) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 10, 1.6, 2.5, -3, 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 ('monaco', 11, 2.6, 2.5, 0, 3.2, 0) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 12, 3.4, 2.5, 0, 3.2, 0) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 13, 3.6, 2.25, -0.5, 2.6, 1.5) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 14, 3.55, 1.95, 0.4, 2.6, 1.5) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 15, 3.3, 1.75, 1, 2.3, 1.4) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 16, 2.95, 1.55, 1.7, 2.1, 1.8) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 17, 2.6, 1.65, 2.6, 2.1, 1.8) +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.4, 1.9, -2.7, 2.3, 1.4) +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.4, 2.2, -3, 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 ('monaco', 20, 2.6, 2.4, 2.8, 2.5, 1) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 21, 3.2, 2.4, 0, 3.2, 0) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 22, 3.7, 2.4, 0, 3.2, 0) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 23, 3.95, 2.3, -0.3, 2.8, 0.8) +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; + +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.3000000000000003, 0.18, 'tile', 0, 'Verstappen', + 0.3, 0.4, 4.05, 2.7, + 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; + +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.5500000000000003, 2, 0.18, 'wood', 0, 'Verstappen', + 0.3, 0.4, 3.85, 2.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; + +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.5500000000000003, 1.2499999999999998, 0.18, 'carpet', 0, 'Hamilton', + 0.3, 1.8, 3.85, 3.05, + 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, 2.8, 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, 2.8, 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, 2.65, -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, 2.3, -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, 2.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.8, 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.95, 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, 2.3, 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, 2.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, 2.85, 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, 2.85, 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, 2.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, 2.65, -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, 2.8, 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, 2.95, 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, 2.65, 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, 2.4, -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, 2.6, 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, 2.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 ('great-britain-silverstone', 19, 0.45, 3.05, 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.7, 2.5, -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, 2.05, 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, 2.4, 1.5707963267948966, 2.4, 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; + +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, 0.5, 4.25, 2.75, + 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; + +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; + diff --git a/server/internal/storage/postgres/migrations/003_f1_seed.sql b/server/internal/storage/postgres/migrations/003_f1_seed.sql new file mode 100644 index 0000000..3db1e98 --- /dev/null +++ b/server/internal/storage/postgres/migrations/003_f1_seed.sql @@ -0,0 +1,588 @@ +-- 003_f1_seed.sql — replace legacy PoC seed (oval, figure-8, sprint-straight +-- and 1/27 toy cars) with the F1 2024 catalog (5 tracks + 5 cars). +-- Idempotent: drops the old system rows before re-inserting. + +DELETE FROM cars WHERE id IN ('compact-1-27-touring', 'compact-1-27-formula'); +DELETE FROM tracks WHERE id IN ('oval-classic', 'figure-eight', 'sprint-straight'); +-- 002_seed.sql — initial system catalog (tracks + cars). +-- Generated from internal/catalog/seeddefs via scripts/genseed. +-- Idempotent: safe to re-apply (ON CONFLICT DO NOTHING). + +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, + 3.5500000000000003, 1.1, 0.18, 'tile', 0, 'Verstappen', + 0.4, 1.4, 3.95, 2.5, + 1700000000000, 1700000000000) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 0, 0.4, 2.2, 0, 3.2, 0) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 1, 1.1, 2.2, 0, 3, 0) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 2, 1.5, 2.05, -0.4, 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 ('monaco', 3, 1.7, 1.85, -0.9, 2.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 ('monaco', 4, 1.55, 1.55, -1.6, 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 ('monaco', 5, 1.25, 1.4, -2.4, 2.1, 1.2) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 6, 0.95, 1.55, 3.141592653589793, 1.9, 1.6) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 7, 0.75, 1.85, 2, 1.8, 2.2) +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.95, 2.15, 2.6, 1.8, 2.6) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 9, 1.2, 2.4, -2.8, 2.1, 1.4) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 10, 1.6, 2.5, -3, 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 ('monaco', 11, 2.6, 2.5, 0, 3.2, 0) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 12, 3.4, 2.5, 0, 3.2, 0) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 13, 3.6, 2.25, -0.5, 2.6, 1.5) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 14, 3.55, 1.95, 0.4, 2.6, 1.5) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 15, 3.3, 1.75, 1, 2.3, 1.4) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 16, 2.95, 1.55, 1.7, 2.1, 1.8) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 17, 2.6, 1.65, 2.6, 2.1, 1.8) +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.4, 1.9, -2.7, 2.3, 1.4) +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.4, 2.2, -3, 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 ('monaco', 20, 2.6, 2.4, 2.8, 2.5, 1) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 21, 3.2, 2.4, 0, 3.2, 0) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 22, 3.7, 2.4, 0, 3.2, 0) +ON CONFLICT (track_id, seq) DO NOTHING; +INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) +VALUES ('monaco', 23, 3.95, 2.3, -0.3, 2.8, 0.8) +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; + +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.3000000000000003, 0.18, 'tile', 0, 'Verstappen', + 0.3, 0.4, 4.05, 2.7, + 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; + +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.5500000000000003, 2, 0.18, 'wood', 0, 'Verstappen', + 0.3, 0.4, 3.85, 2.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; + +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.5500000000000003, 2.4, 0.18, 'carpet', 0, 'Hamilton', + 0.3, 0.25, 3.85, 2.65, + 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; + +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, 0.5, 4.25, 2.75, + 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; + +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; + diff --git a/server/internal/storage/postgres/migrations/004_monaco_rescale.sql b/server/internal/storage/postgres/migrations/004_monaco_rescale.sql new file mode 100644 index 0000000..d1eb601 --- /dev/null +++ b/server/internal/storage/postgres/migrations/004_monaco_rescale.sql @@ -0,0 +1,75 @@ +-- 004_monaco_rescale.sql +-- Replace Monaco centerline (seq 0..23) inserted by 003_f1_seed.sql +-- with the new 51-waypoint CSV at D:/x0gp/tracks_waypoints_10x10.csv +-- Source CSV: 51 waypoints, scale_xy=0.016 (about 1/62.5), v_scale=sqrt(scale)=0.1265. +-- New footprint: 6.46 x 5.37 m, perimeter approx 21.87 m. +-- Idempotent: DELETE old waypoints first, UPDATE tracks metadata, INSERT new ones. + +BEGIN; + +DELETE FROM track_waypoints WHERE track_id = 'monaco'; + +UPDATE tracks SET + length_m = 21.87, + width_m = 6.46, + bounds_min_x = -3.231, + bounds_min_y = -2.684, + bounds_max_x = 3.231, + bounds_max_y = 2.684, + updated_ms = (EXTRACT(EPOCH FROM now()) * 1000)::bigint +WHERE id = 'monaco'; + +-- New centerline (51 waypoints, scale_xy=0.016, v=0.632 m/s) +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); +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); + +COMMIT; diff --git a/server/internal/storage/postgres/migrations/005_races.sql b/server/internal/storage/postgres/migrations/005_races.sql new file mode 100644 index 0000000..8408d33 --- /dev/null +++ b/server/internal/storage/postgres/migrations/005_races.sql @@ -0,0 +1,90 @@ +-- 005_races.sql — race plan, finished race archive, queue. +-- +-- Storage split: +-- * Active races (lobby/countdown/racing) live in-memory in lobby.Service. +-- * Finished races are snapshotted into finished_races on SetRaceStatus(finished). +-- * Race plans describe recurring / one-off scheduled races; the scheduler +-- materializes them into lobby.RaceMeta as their start_at_ms approaches. +-- * Queue entries are per-driver per-race_id subscriptions to the next +-- upcoming races. Driver is auto-attached when a slot opens. +-- +-- Keyset pagination: +-- * finished_races ordered by (finished_ms DESC, id DESC). Cursor encodes +-- (finished_ms, id); lookup uses (finished_ms, id) < (cursor_ms, cursor_id). +-- * race_plans ordered by (start_at_ms ASC, id ASC). Cursor is +-- (start_at_ms, id) with strict-less comparison. + +CREATE TABLE IF NOT EXISTS finished_races ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + host_id TEXT NOT NULL, + host_name TEXT NOT NULL DEFAULT '', + track_id TEXT NOT NULL DEFAULT '', + max_cars INT NOT NULL, + laps INT NOT NULL, + time_limit_s INT NOT NULL, + driver_ids TEXT[] NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'finished' + CHECK (status IN ('finished', 'cancelled')), + created_ms BIGINT NOT NULL, + started_ms BIGINT NOT NULL DEFAULT 0, + finished_ms BIGINT NOT NULL, + duration_ms BIGINT NOT NULL DEFAULT 0, + total_laps INT NOT NULL DEFAULT 0, + total_drivers INT NOT NULL DEFAULT 0, + winner_driver_id TEXT NOT NULL DEFAULT '', + winner_name TEXT NOT NULL DEFAULT '', + best_lap_ms BIGINT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS finished_races_finished_ms_id_idx + ON finished_races (finished_ms DESC, id DESC); +CREATE INDEX IF NOT EXISTS finished_races_host_idx + ON finished_races (host_id); +CREATE INDEX IF NOT EXISTS finished_races_track_idx + ON finished_races (track_id); +CREATE INDEX IF NOT EXISTS finished_races_status_idx + ON finished_races (status); + +-- --------------------------------------------------------------------------- +-- Race plans +-- --------------------------------------------------------------------------- + +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, + host_id TEXT NOT NULL, + host_name TEXT NOT NULL DEFAULT '', + 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); + +-- --------------------------------------------------------------------------- +-- Queue +-- --------------------------------------------------------------------------- + +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); diff --git a/server/internal/storage/postgres/migrations/006_drop_host.sql b/server/internal/storage/postgres/migrations/006_drop_host.sql new file mode 100644 index 0000000..6296094 --- /dev/null +++ b/server/internal/storage/postgres/migrations/006_drop_host.sql @@ -0,0 +1,12 @@ +-- 006_drop_host.sql — remove host_id and host_name from race tables. +-- +-- Races no longer carry a host (driver) reference. A race is owned by a +-- physical track; the "host" concept is removed from the public API and +-- from lobby.RaceMeta. Drivers participate as a flat list of driver_ids. + +ALTER TABLE finished_races DROP COLUMN IF EXISTS host_id; +ALTER TABLE finished_races DROP COLUMN IF EXISTS host_name; +DROP INDEX IF EXISTS finished_races_host_idx; + +ALTER TABLE race_plans DROP COLUMN IF EXISTS host_id; +ALTER TABLE race_plans DROP COLUMN IF EXISTS host_name; diff --git a/server/internal/storage/postgres/migrations/007_podium.sql b/server/internal/storage/postgres/migrations/007_podium.sql new file mode 100644 index 0000000..a732ce0 --- /dev/null +++ b/server/internal/storage/postgres/migrations/007_podium.sql @@ -0,0 +1,16 @@ +-- 007_podium.sql — add podium to finished_races. +-- +-- Podium is the top-3 finishers of a completed race. Stored as JSONB +-- (a small ordered list, rarely queried as a relation). Shape per row: +-- { "position": 1, "driver_id": "driver-alice", +-- "name": "driver-alice", "total_time_ms": 123456 } +-- +-- Ordering: position 1 first. +-- GIN index over the jsonb lets future UI filters (e.g. "races won by +-- driver X") use @> containment queries without a schema change. + +ALTER TABLE finished_races + ADD COLUMN IF NOT EXISTS podium JSONB NOT NULL DEFAULT '[]'::jsonb; + +CREATE INDEX IF NOT EXISTS finished_races_podium_gin + ON finished_races USING GIN (podium); diff --git a/server/internal/storage/postgres/migrations/008_drivers_clans.sql b/server/internal/storage/postgres/migrations/008_drivers_clans.sql new file mode 100644 index 0000000..84bfefd --- /dev/null +++ b/server/internal/storage/postgres/migrations/008_drivers_clans.sql @@ -0,0 +1,37 @@ +-- 008_drivers_clans.sql — drivers and clans. +-- +-- A driver is a person who can join races. Each driver has a unique +-- 3-letter nickname (A-Z, uppercase, exactly 3 characters) and an +-- optional avatar URL. Drivers can belong to at most one clan. +-- +-- A clan is a team that groups drivers. Each clan has a unique 3-letter +-- tag (same rules as the driver nickname) and an optional avatar URL. +-- +-- Constraints: +-- * nickname is exactly 3 uppercase ASCII letters and unique. +-- * tag is exactly 3 uppercase ASCII letters and unique. +-- * driver.clan_id is a soft FK (nullable); ON DELETE SET NULL. + +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); diff --git a/server/internal/storage/postgres/migrations/009_live_persistence.sql b/server/internal/storage/postgres/migrations/009_live_persistence.sql new file mode 100644 index 0000000..87461a3 --- /dev/null +++ b/server/internal/storage/postgres/migrations/009_live_persistence.sql @@ -0,0 +1,68 @@ +-- 009_live_persistence.sql — persistence for live races and lobby drivers. +-- +-- The active race surface (lobby | countdown | racing) is now mirrored +-- in Postgres. finished_races continues to be the canonical archive +-- (no rename yet — it stays in the same shape and the next migration +-- will unify the two under a single `races` table). +-- +-- live_races +-- One row per active race. Status is one of +-- lobby | countdown | racing. The `podium` column is reserved for +-- future use (we may eventually move finished_podium into this +-- table as well). +-- +-- live_race_drivers +-- Many-to-many between live_races and drivers. The order column is +-- the car's slot in the race (0..max_cars-1). +-- +-- lobby_drivers +-- Presence of a driver in the lobby: connected / idle / racing / +-- offline and the current race if racing. + +CREATE TABLE IF NOT EXISTS live_races ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + track_id TEXT NOT NULL, + 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', 'cancelled')), + created_ms BIGINT NOT NULL, + started_ms BIGINT NOT NULL DEFAULT 0, + finished_ms BIGINT NOT NULL DEFAULT 0, + updated_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS live_races_status_idx + ON live_races (status, updated_ms DESC); +CREATE INDEX IF NOT EXISTS live_races_track_idx + ON live_races (track_id); + +CREATE TABLE IF NOT EXISTS live_race_drivers ( + race_id TEXT NOT NULL REFERENCES live_races(id) ON DELETE CASCADE, + driver_id TEXT NOT NULL, + slot INT NOT NULL DEFAULT 0, + joined_ms BIGINT NOT NULL, + PRIMARY KEY (race_id, driver_id) +); + +CREATE INDEX IF NOT EXISTS live_race_drivers_driver_idx + ON live_race_drivers (driver_id); + +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 +); + +CREATE INDEX IF NOT EXISTS lobby_drivers_status_idx + ON lobby_drivers (status); diff --git a/server/internal/storage/postgres/migrations/010_unify_races.sql b/server/internal/storage/postgres/migrations/010_unify_races.sql new file mode 100644 index 0000000..9595173 --- /dev/null +++ b/server/internal/storage/postgres/migrations/010_unify_races.sql @@ -0,0 +1,65 @@ +-- 010_unify_races.sql — unify finished_races and live_races into a single +-- `races` table with a 5-state status. +-- +-- After this migration the `races` table holds both the live +-- surface (lobby | countdown | racing) and the finished archive +-- (finished | cancelled). live_race_drivers keeps the same shape +-- (one row per (race, driver)). The former `finished_races` columns +-- are kept verbatim; the former `live_races` columns are added +-- (started_ms is reused, created_ms was there too). + +-- 1) Rename finished_races -> races. +ALTER TABLE finished_races RENAME TO races; + +-- 2) Drop the old finished-only check and replace with a unified one. +ALTER TABLE races DROP CONSTRAINT IF EXISTS finished_races_status_check; +ALTER TABLE races ADD CONSTRAINT races_status_check + CHECK (status IN ('lobby', 'countdown', 'racing', 'finished', 'cancelled')); + +-- 2a) Add the live-only columns to the (former) finished table. +ALTER TABLE races ADD COLUMN IF NOT EXISTS updated_ms BIGINT NOT NULL DEFAULT 0; + +-- 3) Drop the old index that referenced the old table name and +-- recreate it. The new keyset index sorts on (status, finished_ms +-- DESC, id DESC) so the list view can read live+finished together +-- when needed. +DROP INDEX IF EXISTS finished_races_finished_ms_id_idx; +CREATE INDEX IF NOT EXISTS races_status_finished_idx + ON races (status, finished_ms DESC, id DESC); +DROP INDEX IF EXISTS finished_races_host_idx; -- host_id no longer exists, but the +-- index name may have survived. Defensive drop. +CREATE INDEX IF NOT EXISTS races_started_idx + ON races (status, started_ms DESC, id DESC); + +-- 4) Move live rows from live_races into races BEFORE renaming +-- live_race_drivers, so the FK has somewhere to point. +INSERT INTO races + (id, name, track_id, max_cars, laps, time_limit_s, status, + created_ms, started_ms, finished_ms, updated_ms, podium) +SELECT + id, name, track_id, max_cars, laps, time_limit_s, status, + created_ms, started_ms, 0, updated_ms, '[]'::jsonb +FROM live_races +ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + track_id = EXCLUDED.track_id, + max_cars = EXCLUDED.max_cars, + laps = EXCLUDED.laps, + time_limit_s = EXCLUDED.time_limit_s, + status = EXCLUDED.status, + created_ms = EXCLUDED.created_ms, + started_ms = EXCLUDED.started_ms, + updated_ms = EXCLUDED.updated_ms; + +-- 5) live_race_drivers: rename to race_drivers and re-target the FK +-- at the unified races table. +ALTER TABLE live_race_drivers RENAME TO race_drivers; +ALTER TABLE race_drivers + DROP CONSTRAINT IF EXISTS live_race_drivers_race_id_fkey; +ALTER TABLE race_drivers + ADD CONSTRAINT race_drivers_race_id_fkey + FOREIGN KEY (race_id) REFERENCES races(id) ON DELETE CASCADE; + +-- 6) Drop the now-redundant live tables. +DROP TABLE IF EXISTS live_race_drivers; +DROP TABLE IF EXISTS live_races; diff --git a/server/internal/storage/postgres/pgstore.go b/server/internal/storage/postgres/pgstore.go new file mode 100644 index 0000000..981f2f4 --- /dev/null +++ b/server/internal/storage/postgres/pgstore.go @@ -0,0 +1,373 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/x0gp/server/internal/catalog" +) + +// PgStore implements catalog.Store backed by Postgres + TimescaleDB. +type PgStore struct { + pool *pgxpool.Pool +} + +// NewPgStore wraps an open pool. +func NewPgStore(pool *pgxpool.Pool) *PgStore { + return &PgStore{pool: pool} +} + +// Compile-time check. +var _ catalog.Store = (*PgStore)(nil) + +// --------------------------------------------------------------------------- +// Load +// --------------------------------------------------------------------------- + +// Load reads the full catalog into memory. Used at startup. +func (s *PgStore) Load(ctx context.Context) ([]catalog.TrackMeta, []catalog.CarMeta, error) { + tracks, err := s.loadTracks(ctx) + if err != nil { + return nil, nil, fmt.Errorf("load tracks: %w", err) + } + cars, err := s.loadCars(ctx) + if err != nil { + return nil, nil, fmt.Errorf("load cars: %w", err) + } + return tracks, cars, nil +} + +func (s *PgStore) loadTracks(ctx context.Context) ([]catalog.TrackMeta, error) { + rows, err := s.pool.Query(ctx, ` + SELECT 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 + FROM tracks + ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]catalog.TrackMeta, 0) + for rows.Next() { + var t catalog.TrackMeta + if err := rows.Scan( + &t.ID, &t.Name, &t.Description, &t.AuthorID, &t.Visibility, &t.Scale, + &t.LengthM, &t.WidthM, &t.LaneWidthM, &t.Surface, + &t.BestLapMs, &t.BestLapHolder, + &t.Bounds.MinX, &t.Bounds.MinY, &t.Bounds.MaxX, &t.Bounds.MaxY, + &t.CreatedMs, &t.UpdatedMs, + ); err != nil { + return nil, err + } + // Derive bounds length/width if not stored explicitly. + t.Bounds.LengthM = t.Bounds.MaxX - t.Bounds.MinX + t.Bounds.WidthM = t.Bounds.MaxY - t.Bounds.MinY + out = append(out, t) + } + if err := rows.Err(); err != nil { + return nil, err + } + + // Fetch waypoints and tags per track. + for i := range out { + wps, err := s.loadWaypoints(ctx, out[i].ID) + if err != nil { + return nil, fmt.Errorf("waypoints %s: %w", out[i].ID, err) + } + out[i].Centerline = wps + tags, err := s.loadTags(ctx, out[i].ID) + if err != nil { + return nil, fmt.Errorf("tags %s: %w", out[i].ID, err) + } + out[i].Tags = tags + } + return out, nil +} + +func (s *PgStore) loadWaypoints(ctx context.Context, trackID string) ([]catalog.Waypoint, error) { + rows, err := s.pool.Query(ctx, ` + SELECT x, y, heading_rad, speed_ms, curvature + FROM track_waypoints + WHERE track_id = $1 + ORDER BY seq`, trackID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]catalog.Waypoint, 0) + for rows.Next() { + var w catalog.Waypoint + if err := rows.Scan(&w.X, &w.Y, &w.HeadingRad, &w.SpeedMs, &w.Curvature); err != nil { + return nil, err + } + out = append(out, w) + } + return out, rows.Err() +} + +func (s *PgStore) loadTags(ctx context.Context, trackID string) ([]string, error) { + rows, err := s.pool.Query(ctx, + `SELECT tag FROM track_tags WHERE track_id = $1 ORDER BY tag`, trackID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]string, 0) + for rows.Next() { + var tag string + if err := rows.Scan(&tag); err != nil { + return nil, err + } + out = append(out, tag) + } + return out, rows.Err() +} + +func (s *PgStore) loadCars(ctx context.Context) ([]catalog.CarMeta, error) { + rows, err := s.pool.Query(ctx, ` + SELECT 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, + total_distance_m, total_races, total_laps, best_lap_ms, + created_ms, updated_ms + FROM cars + ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]catalog.CarMeta, 0) + for rows.Next() { + var c catalog.CarMeta + if err := rows.Scan( + &c.ID, &c.Name, &c.OwnerID, &c.Visibility, &c.Scale, + &c.LengthMm, &c.WidthMm, &c.HeightMm, &c.WeightG, + &c.Chassis.Model, &c.Chassis.Material, &c.Chassis.Printed, + &c.Chassis.WheelbaseMm, &c.Chassis.TrackMm, + &c.Motor.Kind, &c.Motor.Class, &c.Motor.KV, &c.Motor.PowerW, + &c.Battery.VoltageV, &c.Battery.CapacityMah, &c.Battery.Cells, &c.Battery.Chemistry, + &c.Drive, &c.TopSpeedMs, &c.ColorHex, &c.AvatarURL, &c.Active, + &c.TotalDistanceM, &c.TotalRaces, &c.TotalLaps, &c.BestLapMs, + &c.CreatedMs, &c.UpdatedMs, + ); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// --------------------------------------------------------------------------- +// Mutators +// --------------------------------------------------------------------------- + +// UpsertTrack writes the track, its waypoints, and its tags in one transaction. +func (s *PgStore) UpsertTrack(ctx context.Context, t catalog.TrackMeta) error { + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, ` + 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 ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + author_id = EXCLUDED.author_id, + visibility = EXCLUDED.visibility, + scale = EXCLUDED.scale, + length_m = EXCLUDED.length_m, + width_m = EXCLUDED.width_m, + lane_width_m = EXCLUDED.lane_width_m, + surface = EXCLUDED.surface, + best_lap_ms = EXCLUDED.best_lap_ms, + best_lap_holder = EXCLUDED.best_lap_holder, + bounds_min_x = EXCLUDED.bounds_min_x, + bounds_min_y = EXCLUDED.bounds_min_y, + bounds_max_x = EXCLUDED.bounds_max_x, + bounds_max_y = EXCLUDED.bounds_max_y, + updated_ms = EXCLUDED.updated_ms`, + t.ID, t.Name, t.Description, t.AuthorID, string(t.Visibility), t.Scale, + t.LengthM, t.WidthM, t.LaneWidthM, string(t.Surface), + t.BestLapMs, t.BestLapHolder, + t.Bounds.MinX, t.Bounds.MinY, t.Bounds.MaxX, t.Bounds.MaxY, + t.CreatedMs, t.UpdatedMs, + ); err != nil { + return err + } + + if _, err := tx.Exec(ctx, `DELETE FROM track_waypoints WHERE track_id = $1`, t.ID); err != nil { + return err + } + for i, w := range t.Centerline { + if _, err := tx.Exec(ctx, ` + INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + t.ID, i, w.X, w.Y, w.HeadingRad, w.SpeedMs, w.Curvature, + ); err != nil { + return err + } + } + + if _, err := tx.Exec(ctx, `DELETE FROM track_tags WHERE track_id = $1`, t.ID); err != nil { + return err + } + for _, tag := range t.Tags { + if _, err := tx.Exec(ctx, + `INSERT INTO track_tags (track_id, tag) VALUES ($1, $2)`, + t.ID, tag, + ); err != nil { + return err + } + } + + return tx.Commit(ctx) +} + +// DeleteTrack removes a track and all child rows (cascade on FK). +func (s *PgStore) DeleteTrack(ctx context.Context, id string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM tracks WHERE id = $1`, id) + return err +} + +// UpsertCar writes a car (insert or update). +func (s *PgStore) UpsertCar(ctx context.Context, c catalog.CarMeta) error { + _, err := s.pool.Exec(ctx, ` + 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, + total_distance_m, total_races, total_laps, best_lap_ms, + created_ms, updated_ms) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + owner_id = EXCLUDED.owner_id, + visibility = EXCLUDED.visibility, + scale = EXCLUDED.scale, + length_mm = EXCLUDED.length_mm, + width_mm = EXCLUDED.width_mm, + height_mm = EXCLUDED.height_mm, + weight_g = EXCLUDED.weight_g, + chassis_model = EXCLUDED.chassis_model, + chassis_material = EXCLUDED.chassis_material, + chassis_printed = EXCLUDED.chassis_printed, + chassis_wheelbase_mm= EXCLUDED.chassis_wheelbase_mm, + chassis_track_mm = EXCLUDED.chassis_track_mm, + motor_kind = EXCLUDED.motor_kind, + motor_class = EXCLUDED.motor_class, + motor_kv = EXCLUDED.motor_kv, + motor_power_w = EXCLUDED.motor_power_w, + battery_voltage_v = EXCLUDED.battery_voltage_v, + battery_capacity_mah= EXCLUDED.battery_capacity_mah, + battery_cells = EXCLUDED.battery_cells, + battery_chemistry = EXCLUDED.battery_chemistry, + drive = EXCLUDED.drive, + top_speed_ms = EXCLUDED.top_speed_ms, + color_hex = EXCLUDED.color_hex, + avatar_url = EXCLUDED.avatar_url, + active = EXCLUDED.active, + total_distance_m = EXCLUDED.total_distance_m, + total_races = EXCLUDED.total_races, + total_laps = EXCLUDED.total_laps, + best_lap_ms = EXCLUDED.best_lap_ms, + updated_ms = EXCLUDED.updated_ms`, + c.ID, c.Name, c.OwnerID, string(c.Visibility), c.Scale, + c.LengthMm, c.WidthMm, c.HeightMm, c.WeightG, + c.Chassis.Model, c.Chassis.Material, c.Chassis.Printed, + c.Chassis.WheelbaseMm, c.Chassis.TrackMm, + c.Motor.Kind, c.Motor.Class, c.Motor.KV, c.Motor.PowerW, + c.Battery.VoltageV, c.Battery.CapacityMah, c.Battery.Cells, c.Battery.Chemistry, + string(c.Drive), c.TopSpeedMs, c.ColorHex, c.AvatarURL, c.Active, + c.TotalDistanceM, c.TotalRaces, c.TotalLaps, c.BestLapMs, + c.CreatedMs, c.UpdatedMs, + ) + return err +} + +// DeleteCar removes a car by id. +func (s *PgStore) DeleteCar(ctx context.Context, id string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM cars WHERE id = $1`, id) + return err +} + +// UpdateCarStats applies a partial mutation to the stats columns. We +// first read the row, run the callback, then write the mutated fields +// back. This mirrors the in-memory implementation used by tests. +func (s *PgStore) UpdateCarStats(ctx context.Context, id string, fn func(*catalog.CarMeta)) error { + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + var c catalog.CarMeta + err = tx.QueryRow(ctx, ` + SELECT 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, + total_distance_m, total_races, total_laps, best_lap_ms, + created_ms, updated_ms + FROM cars WHERE id = $1 FOR UPDATE`, id, + ).Scan( + &c.ID, &c.Name, &c.OwnerID, &c.Visibility, &c.Scale, + &c.LengthMm, &c.WidthMm, &c.HeightMm, &c.WeightG, + &c.Chassis.Model, &c.Chassis.Material, &c.Chassis.Printed, + &c.Chassis.WheelbaseMm, &c.Chassis.TrackMm, + &c.Motor.Kind, &c.Motor.Class, &c.Motor.KV, &c.Motor.PowerW, + &c.Battery.VoltageV, &c.Battery.CapacityMah, &c.Battery.Cells, &c.Battery.Chemistry, + &c.Drive, &c.TopSpeedMs, &c.ColorHex, &c.AvatarURL, &c.Active, + &c.TotalDistanceM, &c.TotalRaces, &c.TotalLaps, &c.BestLapMs, + &c.CreatedMs, &c.UpdatedMs, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return catalog.ErrNotFound + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return err + } + return err + } + fn(&c) + if _, err := tx.Exec(ctx, ` + UPDATE cars SET + total_distance_m = $2, + total_races = $3, + total_laps = $4, + best_lap_ms = $5, + updated_ms = $6 + WHERE id = $1`, + c.ID, c.TotalDistanceM, c.TotalRaces, c.TotalLaps, c.BestLapMs, c.UpdatedMs, + ); err != nil { + return err + } + return tx.Commit(ctx) +} \ No newline at end of file diff --git a/server/internal/storage/postgres/postgres.go b/server/internal/storage/postgres/postgres.go new file mode 100644 index 0000000..b53e667 --- /dev/null +++ b/server/internal/storage/postgres/postgres.go @@ -0,0 +1,226 @@ +// 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. +package postgres + +import ( + "context" + "embed" + "fmt" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// Config captures the connection parameters. URL takes precedence over the +// individual fields if non-empty. +type Config struct { + URL string + Host string + Port int + User string + Password string + Database string + SSLMode string + MaxConns int32 + MinConns int32 + ConnectTimeout time.Duration +} + +// Open establishes a pgxpool, pings the database and returns it. The +// returned pool must be closed by the caller. +func Open(ctx context.Context, cfg Config) (*pgxpool.Pool, error) { + dsn, err := cfg.DSN() + if err != nil { + return nil, err + } + + pcfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("parse dsn: %w", err) + } + if cfg.MaxConns > 0 { + pcfg.MaxConns = cfg.MaxConns + } + if cfg.MinConns > 0 { + pcfg.MinConns = cfg.MinConns + } + if cfg.ConnectTimeout > 0 { + pcfg.ConnConfig.ConnectTimeout = cfg.ConnectTimeout + } + + pool, err := pgxpool.NewWithConfig(ctx, pcfg) + if err != nil { + return nil, fmt.Errorf("connect: %w", err) + } + + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := pool.Ping(pingCtx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping: %w", err) + } + return pool, nil +} + +// DSN builds the postgres connection string. +func (c Config) DSN() (string, error) { + if c.URL != "" { + return c.URL, nil + } + if c.Host == "" { + return "", fmt.Errorf("postgres: host required") + } + port := c.Port + if port == 0 { + port = 5432 + } + ssl := c.SSLMode + if ssl == "" { + ssl = "disable" + } + return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s", + c.User, c.Password, c.Host, port, c.Database, ssl), nil +} + +// Migrate applies any pending SQL migrations from the embedded migrations/ +// directory. Migrations are tracked in the schema_migrations table and run +// in lexical filename order. Each migration runs in its own transaction. +func Migrate(ctx context.Context, pool *pgxpool.Pool) error { + if _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`); err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + + entries, err := migrationsFS.ReadDir("migrations") + if err != nil { + return fmt.Errorf("read migrations: %w", err) + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { + names = append(names, e.Name()) + } + } + sort.Strings(names) + + applied := map[string]struct{}{} + rows, err := pool.Query(ctx, `SELECT version FROM schema_migrations`) + if err != nil { + return fmt.Errorf("list applied migrations: %w", err) + } + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + rows.Close() + return err + } + applied[v] = struct{}{} + } + rows.Close() + + for _, name := range names { + if _, ok := applied[name]; ok { + continue + } + body, err := migrationsFS.ReadFile("migrations/" + name) + if err != nil { + return fmt.Errorf("read %s: %w", name, err) + } + if err := applyMigration(ctx, pool, name, string(body)); err != nil { + return fmt.Errorf("apply %s: %w", name, err) + } + } + return nil +} + +func applyMigration(ctx context.Context, pool *pgxpool.Pool, name, body string) error { + tx, err := pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + // pgx's simple-protocol Exec() rejects multi-statement strings. Split + // the body on ";\n" boundaries and run each statement individually. + for i, stmt := range splitStatements(body) { + if _, err := tx.Exec(ctx, stmt); err != nil { + preview := stmt + if len(preview) > 120 { + preview = preview[:120] + "..." + } + return fmt.Errorf("statement %d failed: %w; sql=%q", i+1, err, preview) + } + } + if _, err := tx.Exec(ctx, + `INSERT INTO schema_migrations (version) VALUES ($1)`, name); err != nil { + return err + } + return tx.Commit(ctx) +} + +// splitStatements splits a SQL script on top-level ";" boundaries. It +// is deliberately simple: it ignores ";" inside string literals and +// comments, which is fine for our migration files (no literal ";" +// in any INSERT). +func splitStatements(body string) []string { + var ( + out []string + buf strings.Builder + inStr bool + escNext bool + ) + for i := 0; i < len(body); i++ { + c := body[i] + if escNext { + buf.WriteByte(c) + escNext = false + continue + } + if c == '\\' && inStr { + buf.WriteByte(c) + escNext = true + continue + } + if c == '\'' { + inStr = !inStr + buf.WriteByte(c) + continue + } + if c == '-' && !inStr && i+1 < len(body) && body[i+1] == '-' { + // line comment until \n + for i < len(body) && body[i] != '\n' { + buf.WriteByte(body[i]) + i++ + } + if i < len(body) { + buf.WriteByte('\n') + } + continue + } + if c == ';' && !inStr { + s := strings.TrimSpace(buf.String()) + if s != "" { + out = append(out, s) + } + buf.Reset() + continue + } + buf.WriteByte(c) + } + if rest := strings.TrimSpace(buf.String()); rest != "" { + out = append(out, rest) + } + return out +} \ No newline at end of file diff --git a/server/internal/transport/codec.go b/server/internal/transport/codec.go new file mode 100644 index 0000000..92bfe60 --- /dev/null +++ b/server/internal/transport/codec.go @@ -0,0 +1,836 @@ +// 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). +package transport + +import ( + "encoding/json" + "fmt" + "time" +) + +// MessageType identifies the kind of payload. +type MessageType string + +const ( + // Client -> Server. + TypeClientHello MessageType = "client_hello" + TypeJoinRace MessageType = "join_race" + TypeLeaveRace MessageType = "leave_race" + TypeInputState MessageType = "input_state" + TypeChatMessage MessageType = "chat_message" + TypePing MessageType = "ping" + + // Client -> Server (lobby). + TypeLobbyList MessageType = "lobby_list" + TypeLobbyCreate MessageType = "lobby_create" + TypeLobbyQuickPlay MessageType = "lobby_quick_play" + TypeLobbyJoinRace MessageType = "lobby_join_race" + TypeLobbyLeaveRace MessageType = "lobby_leave_race" + TypeLobbyKickDriver MessageType = "lobby_kick_driver" + TypeLobbyDelete MessageType = "lobby_delete" + + // Client -> Server (catalog: tracks). + TypeTrackList MessageType = "track_list" + TypeTrackGet MessageType = "track_get" + TypeTrackCreate MessageType = "track_create" + TypeTrackUpdate MessageType = "track_update" + TypeTrackDelete MessageType = "track_delete" + + // Client -> Server (catalog: cars). + TypeCarList MessageType = "car_list" + TypeCarGet MessageType = "car_get" + TypeCarCreate MessageType = "car_create" + TypeCarUpdate MessageType = "car_update" + TypeCarDelete MessageType = "car_delete" + + // Client -> Server (stats). + TypeStatsRequest MessageType = "stats_request" + + // Server -> Client. + TypeServerHello MessageType = "server_hello" + TypeRaceSnapshot MessageType = "race_snapshot" + TypeInputAck MessageType = "input_ack" + TypeCorrection MessageType = "correction" + TypeRaceEvent MessageType = "race_event" + TypePong MessageType = "pong" + TypeError MessageType = "error" + + // Server -> Client (lobby). + TypeLobbySnapshot MessageType = "lobby_snapshot" + + // Server -> Client (catalog). + TypeTrackSnapshot MessageType = "track_snapshot" + TypeTrackAck MessageType = "track_ack" + TypeCarSnapshot MessageType = "car_snapshot" + TypeCarAck MessageType = "car_ack" + TypeCatalogSummary MessageType = "catalog_summary" + + // Server -> Client (stats). + TypeStatsSnapshot MessageType = "stats_snapshot" + TypeDriverProfile MessageType = "driver_profile" + TypeLapRecorded MessageType = "lap_recorded" + TypeRaceResult MessageType = "race_result" + TypeRaceStandings MessageType = "race_standings" +) + +// Envelope wraps every WS frame with a type discriminator. +type Envelope struct { + Type MessageType `json:"type"` + Seq uint64 `json:"seq,omitempty"` + TSMs int64 `json:"ts_ms,omitempty"` + Payload any `json:"payload,omitempty"` +} + +// ClientHello is the first message from a client. +type ClientHello struct { + Version string `json:"version"` + Token string `json:"token,omitempty"` // empty in dev mode + Locale string `json:"locale,omitempty"` + ClientID string `json:"client_id,omitempty"` // for reconnect +} + +// ServerHello is the response to ClientHello. +type ServerHello struct { + SessionID string `json:"session_id"` + RaceTick uint64 `json:"race_tick"` + ServerVersion string `json:"server_version"` + Config struct { + TickRate int `json:"tick_rate_hz"` + SnapshotRate int `json:"snapshot_rate_hz"` + MaxCars int `json:"max_cars"` + } `json:"config"` +} + +// JoinRace is sent by the client to enter a race session. +type JoinRace struct { + RaceID string `json:"race_id"` + CarSlot int `json:"car_slot"` // 0-3 for PoC (4 cars) + CarName string `json:"car_name,omitempty"` +} + +// LeaveRace is sent when leaving a race. +type LeaveRace struct { + RaceID string `json:"race_id"` +} + +// InputState is the high-rate control packet (60-120 Hz). +type InputState struct { + Steering float64 `json:"steering"` // -1.0 .. 1.0 + Throttle float64 `json:"throttle"` // 0.0 .. 1.0 + Brake float64 `json:"brake"` // 0.0 .. 1.0 + Gear int `json:"gear"` // -1 reverse, 0 neutral, 1..6 forward + Buttons uint32 `json:"buttons,omitempty"` +} + +// ChatMessage for lobby chat (rate-limited). +type ChatMessage struct { + Text string `json:"text"` +} + +// Ping for latency measurement. +type Ping struct { + ClientTSMs int64 `json:"client_ts_ms"` +} + +// RaceSnapshot is the authoritative state of the race, broadcast N times/sec. +type RaceSnapshot struct { + Tick uint64 `json:"tick"` + TSMs int64 `json:"ts_ms"` + Elapsed float64 `json:"elapsed_s"` + Cars []CarInfo `json:"cars"` +} + +// CarInfo describes one car in the race. +type CarInfo struct { + ID string `json:"id"` + DriverName string `json:"driver_name"` + X float64 `json:"x"` // metres + Y float64 `json:"y"` // metres + Heading float64 `json:"heading"` // radians + Speed float64 `json:"speed"` // m/s + Lap int `json:"lap"` + Sector int `json:"sector"` // 0..2 + LastLapMs int64 `json:"last_lap_ms"` + BestLapMs int64 `json:"best_lap_ms"` + InputApplied struct { + Steering float64 `json:"steering"` + Throttle float64 `json:"throttle"` + Brake float64 `json:"brake"` + } `json:"input_applied"` + DNF bool `json:"dnf"` +} + +// InputAck acknowledges an input packet (lightweight). +type InputAck struct { + Seq uint64 `json:"seq"` + AppliedAtMs int64 `json:"applied_at_ms"` + ServerTickMs int64 `json:"server_tick_ms"` +} + +// Correction is sent when client prediction diverged from server. +type Correction struct { + Tick uint64 `json:"tick"` + DeltaSteering float64 `json:"delta_steering"` + DeltaThrottle float64 `json:"delta_throttle"` + Reason string `json:"reason"` +} + +// RaceEvent for major state transitions. +type RaceEvent struct { + RaceID string `json:"race_id"` + Event string `json:"event"` // "start", "lap", "sector", "dnf", "finish" + CarID string `json:"car_id,omitempty"` + Sector int `json:"sector,omitempty"` + LapMs int64 `json:"lap_ms,omitempty"` + TSMs int64 `json:"ts_ms"` +} + +// Pong for RTT measurement. +type Pong struct { + ClientTSMs int64 `json:"client_ts_ms"` + ServerTSMs int64 `json:"server_ts_ms"` +} + +// ErrorMsg is sent on protocol or validation errors. +type ErrorMsg struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// ------------------------------------------------------------------------- +// Lobby payloads +// ------------------------------------------------------------------------- + +// LobbyRace is the wire form of lobby.RaceMeta (kept separate to avoid +// a circular import between transport and lobby). +type LobbyRace struct { + ID string `json:"id"` + Name string `json:"name"` + TrackID string `json:"track_id"` + MaxCars int `json:"max_cars"` + Laps int `json:"laps"` + TimeLimitS int `json:"time_limit_s"` + DriverIDs []string `json:"driver_ids"` + Status string `json:"status"` + CreatedMs int64 `json:"created_ms"` + StartedMs int64 `json:"started_ms,omitempty"` + // Podium is populated only for finished races. Empty for live ones. + Podium []RacePodiumEntry `json:"podium,omitempty"` +} + +// RacePodiumEntry is one row of a finished race's top-3. +type RacePodiumEntry struct { + Position int `json:"position" example:"1"` + DriverID string `json:"driver_id" example:"driver-alice"` + Name string `json:"name" example:"driver-alice"` + TotalTimeMs int64 `json:"total_time_ms" example:"123456"` +} + +// LobbyDriver is the wire form of lobby.DriverMeta. +type LobbyDriver struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` // idle | racing | offline + RaceID string `json:"race_id,omitempty"` + ConnectedMs int64 `json:"connected_ms"` + LastSeenMs int64 `json:"last_seen_ms"` + Country string `json:"country,omitempty"` + AvatarHue int `json:"avatar_hue,omitempty"` +} + +// LobbySnapshot is the full lobby state, broadcast on change. +type LobbySnapshot struct { + GeneratedMs int64 `json:"generated_ms"` + Version uint64 `json:"version"` + Races []LobbyRace `json:"races"` + Drivers []LobbyDriver `json:"drivers"` +} + +// LobbyCreate is sent by a client to create a race (becomes host). +type LobbyCreate struct { + RaceID string `json:"race_id,omitempty"` + Name string `json:"name"` + TrackID string `json:"track_id,omitempty"` + MaxCars int `json:"max_cars,omitempty"` + Laps int `json:"laps,omitempty"` + TimeLimitS int `json:"time_limit_s,omitempty"` +} + +// LobbyJoinRace is sent by a client to join a specific race. +type LobbyJoinRace struct { + RaceID string `json:"race_id"` + CarSlot int `json:"car_slot,omitempty"` + CarName string `json:"car_name,omitempty"` +} + +// LobbyLeaveRace moves a driver from race back to lobby. +type LobbyLeaveRace struct { + RaceID string `json:"race_id,omitempty"` +} + +// LobbyKickDriver is sent by the host to remove another driver. +type LobbyKickDriver struct { + RaceID string `json:"race_id"` + DriverID string `json:"driver_id"` +} + +// LobbyDelete removes the race entirely (host only). +type LobbyDelete struct { + RaceID string `json:"race_id"` +} + +// LobbyCreatedAck confirms a successful lobby_create. +type LobbyCreatedAck struct { + Ok bool `json:"ok"` + Race LobbyRace `json:"race,omitempty"` + Error string `json:"error,omitempty"` + Driver LobbyDriver `json:"driver,omitempty"` +} + +// LobbyJoinAck confirms lobby_join_race / lobby_quick_play. +type LobbyJoinAck struct { + Ok bool `json:"ok"` + Race LobbyRace `json:"race,omitempty"` + Error string `json:"error,omitempty"` +} + +// ------------------------------------------------------------------------- +// Catalog payloads (tracks and cars) +// ------------------------------------------------------------------------- + +// TrackWaypoint is the wire form of catalog.Waypoint. +type TrackWaypoint struct { + X float64 `json:"x"` + Y float64 `json:"y"` + HeadingRad float64 `json:"heading_rad"` + SpeedMs float64 `json:"speed_ms"` + Curvature float64 `json:"curvature"` +} + +// TrackBounds is the wire form of catalog.Bounds. +type TrackBounds struct { + MinX float64 `json:"min_x"` + MinY float64 `json:"min_y"` + MaxX float64 `json:"max_x"` + MaxY float64 `json:"max_y"` + LengthM float64 `json:"length_m"` + WidthM float64 `json:"width_m"` +} + +// TrackWire is the wire form of catalog.TrackMeta. +type TrackWire struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + AuthorID string `json:"author_id"` + Visibility string `json:"visibility"` + Scale int `json:"scale"` + LengthM float64 `json:"length_m"` + WidthM float64 `json:"width_m"` + LaneWidthM float64 `json:"lane_width_m"` + Bounds TrackBounds `json:"bounds"` + Surface string `json:"surface"` + Tags []string `json:"tags,omitempty"` + Centerline []TrackWaypoint `json:"centerline"` + BestLapMs int64 `json:"best_lap_ms"` + BestLapHolder string `json:"best_lap_holder,omitempty"` + CreatedMs int64 `json:"created_ms"` + UpdatedMs int64 `json:"updated_ms"` +} + +// TrackListRequest: payload { "scope": "all|system|public|private", "tag": "..." }. +type TrackListRequest struct { + Scope string `json:"scope,omitempty"` + Tag string `json:"tag,omitempty"` +} + +// TrackGetRequest: payload { "id": "..." }. +type TrackGetRequest struct { + ID string `json:"id"` +} + +// TrackCreateRequest: full TrackWire (server fills ID if empty). +type TrackCreateRequest struct { + Track TrackWire `json:"track"` +} + +// TrackUpdateRequest: ID + patch fields. +type TrackUpdateRequest struct { + ID string `json:"id"` + Patch TrackPatch `json:"patch"` +} + +// TrackPatch: any field may be omitted (empty string / zero means unchanged). +type TrackPatch struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Visibility *string `json:"visibility,omitempty"` + Scale *int `json:"scale,omitempty"` + LengthM *float64 `json:"length_m,omitempty"` + WidthM *float64 `json:"width_m,omitempty"` + LaneWidthM *float64 `json:"lane_width_m,omitempty"` + Surface *string `json:"surface,omitempty"` + Tags *[]string `json:"tags,omitempty"` + Centerline *[]TrackWaypoint `json:"centerline,omitempty"` +} + +// TrackDeleteRequest: payload { "id": "..." }. +type TrackDeleteRequest struct { + ID string `json:"id"` +} + +// TrackSnapshot: full catalog tracks feed (broadcast on change). +type TrackSnapshot struct { + GeneratedMs int64 `json:"generated_ms"` + Version uint64 `json:"version"` + Tracks []TrackWire `json:"tracks"` +} + +// TrackAck: response to create/update/delete operations. +type TrackAck struct { + Ok bool `json:"ok"` + ID string `json:"id,omitempty"` + Error string `json:"error,omitempty"` + Track TrackWire `json:"track,omitempty"` +} + +// MotorWire is the wire form of catalog.MotorSpec. +type MotorWire struct { + Kind string `json:"kind"` + Class string `json:"class"` + KV int `json:"kv"` + PowerW float64 `json:"power_w"` +} + +// BatteryWire is the wire form of catalog.BatterySpec. +type BatteryWire struct { + VoltageV float64 `json:"voltage_v"` + CapacityMah int `json:"capacity_mah"` + Cells int `json:"cells"` + Chemistry string `json:"chemistry"` +} + +// ChassisWire is the wire form of catalog.ChassisSpec. +type ChassisWire struct { + Model string `json:"model"` + Material string `json:"material"` + Printed bool `json:"printed"` + WheelbaseMm float64 `json:"wheelbase_mm"` + TrackMm float64 `json:"track_mm"` +} + +// CarWire is the wire form of catalog.CarMeta. +type CarWire struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerID string `json:"owner_id"` + Visibility string `json:"visibility"` + Scale int `json:"scale"` + + LengthMm float64 `json:"length_mm"` + WidthMm float64 `json:"width_mm"` + HeightMm float64 `json:"height_mm"` + WeightG float64 `json:"weight_g"` + + Chassis ChassisWire `json:"chassis"` + Motor MotorWire `json:"motor"` + Battery BatteryWire `json:"battery"` + Drive string `json:"drive"` + + TopSpeedMs float64 `json:"top_speed_ms"` + + ColorHex string `json:"color_hex"` + AvatarURL string `json:"avatar_url,omitempty"` + + Active bool `json:"active"` + + TotalDistanceM float64 `json:"total_distance_m"` + TotalRaces int `json:"total_races"` + TotalLaps int `json:"total_laps"` + BestLapMs int64 `json:"best_lap_ms"` + + CreatedMs int64 `json:"created_ms"` + UpdatedMs int64 `json:"updated_ms"` +} + +// CarListRequest: payload { "scope": "all|system|public|private", "owner_id": "..." }. +type CarListRequest struct { + Scope string `json:"scope,omitempty"` + OwnerID string `json:"owner_id,omitempty"` +} + +// CarGetRequest: payload { "id": "..." }. +type CarGetRequest struct { + ID string `json:"id"` +} + +// CarCreateRequest: full CarWire (server fills ID if empty). +type CarCreateRequest struct { + Car CarWire `json:"car"` +} + +// CarUpdateRequest: ID + patch fields. +type CarUpdateRequest struct { + ID string `json:"id"` + Patch CarPatch `json:"patch"` +} + +// CarPatch: any field may be omitted (nil pointer = unchanged). +type CarPatch struct { + Name *string `json:"name,omitempty"` + Visibility *string `json:"visibility,omitempty"` + Scale *int `json:"scale,omitempty"` + LengthMm *float64 `json:"length_mm,omitempty"` + WidthMm *float64 `json:"width_mm,omitempty"` + HeightMm *float64 `json:"height_mm,omitempty"` + WeightG *float64 `json:"weight_g,omitempty"` + Chassis *ChassisWire `json:"chassis,omitempty"` + Motor *MotorWire `json:"motor,omitempty"` + Battery *BatteryWire `json:"battery,omitempty"` + Drive *string `json:"drive,omitempty"` + TopSpeedMs *float64 `json:"top_speed_ms,omitempty"` + ColorHex *string `json:"color_hex,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` + Active *bool `json:"active,omitempty"` +} + +// CarDeleteRequest: payload { "id": "..." }. +type CarDeleteRequest struct { + ID string `json:"id"` +} + +// CarSnapshot: full catalog cars feed (broadcast on change). +type CarSnapshot struct { + GeneratedMs int64 `json:"generated_ms"` + Version uint64 `json:"version"` + Cars []CarWire `json:"cars"` +} + +// CarAck: response to create/update/delete operations. +type CarAck struct { + Ok bool `json:"ok"` + ID string `json:"id,omitempty"` + Error string `json:"error,omitempty"` + Car CarWire `json:"car,omitempty"` +} + +// CatalogSummary: lightweight counters (for header chips). +type CatalogSummary struct { + TracksTotal int `json:"tracks_total"` + TracksSystem int `json:"tracks_system"` + TracksPublic int `json:"tracks_public"` + TracksPrivate int `json:"tracks_private"` + CarsTotal int `json:"cars_total"` + CarsSystem int `json:"cars_system"` + CarsPublic int `json:"cars_public"` + CarsPrivate int `json:"cars_private"` + CarsActive int `json:"cars_active"` + TSMs int64 `json:"ts_ms"` +} + +// ------------------------------------------------------------------------- +// Races: list / upcoming / queue / plans +// ------------------------------------------------------------------------- + +// RaceListResponse is the keyset-paginated body returned by /api/races. +type RaceListResponse struct { + Items []LobbyRace `json:"items"` + NextCursor string `json:"next_cursor,omitempty" example:"MTcwMDAwMDAwMDAwMDpyYWNlLWFiYzpmaW5pc2hlZA"` + Count int `json:"count"` +} + +// RaceUpcomingItem is one row in the upcoming list. +type RaceUpcomingItem struct { + Meta LobbyRace `json:"meta"` + QueueLen int `json:"queue_len"` + PlanID string `json:"plan_id,omitempty"` + StartAtMs int64 `json:"start_at_ms"` +} + +// RaceUpcomingResponse is the body returned by /api/races/upcoming. +type RaceUpcomingResponse struct { + Items []RaceUpcomingItem `json:"items"` + Count int `json:"count"` +} + +// RaceQueueEntry is one (driver, race) subscription. +type RaceQueueEntry struct { + DriverID string `json:"driver_id" example:"driver-42"` + RaceID string `json:"race_id" example:"race-1730000-7"` + PlanID string `json:"plan_id,omitempty" example:"plan-1730000-1"` + EnqueuedMs int64 `json:"enqueued_ms" example:"1730000000000"` +} + +// RaceQueueJoinRequest is the body of POST /api/races/queue/join. +// Either driver_id (body) or X-Driver-Id header is accepted. +type RaceQueueJoinRequest struct { + DriverID string `json:"driver_id" example:"driver-42"` + Limit int `json:"limit,omitempty" example:"3"` +} + +// RaceQueueJoinResponse is what /api/races/queue/join returns. +type RaceQueueJoinResponse struct { + Joined []RaceQueueEntry `json:"joined"` + Skipped []string `json:"skipped,omitempty" example:"race-1,race-2"` +} + +// RaceQueueListResponse is what GET /api/races/queue returns. +type RaceQueueListResponse struct { + Items []RaceQueueEntry `json:"items"` + Count int `json:"count"` +} + +// RacePlan is the wire form of races.RacePlan. +type RacePlan struct { + ID string `json:"id" example:"plan-1730000-1"` + Name string `json:"name" example:"Daily Monza"` + TrackID string `json:"track_id" example:"default"` + MaxCars int `json:"max_cars" example:"4"` + Laps int `json:"laps" example:"10"` + TimeLimitS int `json:"time_limit_s,omitempty"` + StartAtMs int64 `json:"start_at_ms" example:"1730000000000"` + IntervalS int `json:"interval_s,omitempty" example:"3600"` + Count int `json:"count,omitempty" example:"0"` + Enabled bool `json:"enabled" example:"true"` + CreatedMs int64 `json:"created_ms"` + UpdatedMs int64 `json:"updated_ms"` + NextFireMs int64 `json:"next_fire_ms"` + FiresDone int `json:"fires_done"` +} + +// RacePlanCreateRequest is the body of POST /api/races/plans. +type RacePlanCreateRequest struct { + ID string `json:"id,omitempty" example:"plan-1730000-1"` + Name string `json:"name" example:"Daily Monza"` + TrackID string `json:"track_id,omitempty" example:"default"` + MaxCars int `json:"max_cars" example:"4"` + Laps int `json:"laps,omitempty" example:"10"` + TimeLimitS int `json:"time_limit_s,omitempty"` + StartAtMs int64 `json:"start_at_ms" example:"1730000000000"` + IntervalS int `json:"interval_s,omitempty" example:"3600"` + Count int `json:"count,omitempty" example:"0"` + Enabled *bool `json:"enabled,omitempty" example:"true"` +} + +// RacePlanListResponse is the keyset-paginated body returned by GET /api/races/plans. +type RacePlanListResponse struct { + Items []RacePlan `json:"items"` + NextCursor string `json:"next_cursor,omitempty"` + Count int `json:"count"` +} + +// ------------------------------------------------------------------------- +// Drivers and clans +// ------------------------------------------------------------------------- + +// Driver is the wire form of a driver record. +type Driver struct { + ID string `json:"id" example:"driver-1782000-123"` + Nickname string `json:"nickname" example:"ACE"` + Name string `json:"name" 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"` + CreatedMs int64 `json:"created_ms"` + UpdatedMs int64 `json:"updated_ms"` +} + +// DriverCreateRequest is the body of POST /api/drivers. +type DriverCreateRequest struct { + ID string `json:"id,omitempty" example:"driver-1782000-123"` + Nickname string `json:"nickname" example:"ACE"` + Name string `json:"name" 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"` +} + +// DriverUpdateRequest is the body of PUT /api/drivers/{id}. +type DriverUpdateRequest struct { + Name string `json:"name,omitempty" example:"Alice"` + AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/u/ace.png"` + ClanID *string `json:"clan_id,omitempty" example:"clan-1782000-1"` +} + +// DriverListResponse is the body returned by GET /api/drivers. +type DriverListResponse struct { + Items []Driver `json:"items"` + Count int `json:"count"` +} + +// Clan is the wire form of a clan record. +type Clan struct { + ID string `json:"id" example:"clan-1782000-1"` + Tag string `json:"tag" example:"ACE"` + Name string `json:"name" example:"Ace Racing"` + AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/c/ace.png"` + CreatedMs int64 `json:"created_ms"` + UpdatedMs int64 `json:"updated_ms"` +} + +// ClanCreateRequest is the body of POST /api/clans. +type ClanCreateRequest struct { + ID string `json:"id,omitempty" example:"clan-1782000-1"` + Tag string `json:"tag" example:"ACE"` + Name string `json:"name" example:"Ace Racing"` + AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/c/ace.png"` +} + +// ClanUpdateRequest is the body of PUT /api/clans/{id}. +type ClanUpdateRequest struct { + Name string `json:"name,omitempty" example:"Ace Racing"` + AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/c/ace.png"` +} + +// ClanListResponse is the body returned by GET /api/clans. +type ClanListResponse struct { + Items []Clan `json:"items"` + Count int `json:"count"` +} + +// ------------------------------------------------------------------------- +// Stats payloads +// ------------------------------------------------------------------------- + +// StatsServer mirrors stats.ServerStats for the wire. +type StatsServer struct { + StartedMs int64 `json:"started_ms"` + UptimeMs int64 `json:"uptime_ms"` + CurrentClients int64 `json:"current_clients"` + PeakClients int64 `json:"peak_clients"` + TotalConnections uint64 `json:"total_connections"` + TotalDisconnects uint64 `json:"total_disconnects"` + TotalRacesCreated uint64 `json:"total_races_created"` + TotalRacesStarted uint64 `json:"total_races_started"` + TotalRacesFinished uint64 `json:"total_races_finished"` + TotalLapsRecorded uint64 `json:"total_laps_recorded"` + TotalInputsRecv uint64 `json:"total_inputs_received"` + TotalSnapshotsSent uint64 `json:"total_snapshots_sent"` + SnapshotsDropped uint64 `json:"snapshots_dropped"` + AvgRTTMs float64 `json:"avg_rtt_ms"` +} + +// StatsDriver mirrors stats.DriverProfile for the wire. +type StatsDriver struct { + ID string `json:"id"` + Name string `json:"name"` + TotalRacesStarted uint64 `json:"total_races_started"` + TotalRacesFinished uint64 `json:"total_races_finished"` + TotalLaps uint64 `json:"total_laps"` + BestLapMs int64 `json:"best_lap_ms"` + LastLapMs int64 `json:"last_lap_ms"` + TotalDistanceM float64 `json:"total_distance_m"` + TotalPlaytimeMs int64 `json:"total_playtime_ms"` + Wins uint64 `json:"wins"` + Podiums uint64 `json:"podiums"` + LastSeenMs int64 `json:"last_seen_ms"` +} + +// StatsLap is one lap in the recent-laps feed. +type StatsLap struct { + RaceID string `json:"race_id"` + DriverID string `json:"driver_id"` + LapMs int64 `json:"lap_ms"` + Sector1Ms int64 `json:"sector1_ms"` + Sector2Ms int64 `json:"sector2_ms"` + Sector3Ms int64 `json:"sector3_ms"` + Position int `json:"position,omitempty"` + TSMs int64 `json:"ts_ms"` +} + +// StatsRaceResult is the final classification of a race for one driver. +type StatsRaceResult struct { + RaceID string `json:"race_id"` + DriverID string `json:"driver_id"` + FinishedAtMs int64 `json:"finished_at_ms"` + Position int `json:"position"` + TotalLaps int `json:"total_laps"` + BestLapMs int64 `json:"best_lap_ms"` + TotalTimeMs int64 `json:"total_time_ms"` + DNF bool `json:"dnf"` +} + +// StatsSnapshot is the full server-side stats payload. +type StatsSnapshot struct { + Server StatsServer `json:"server"` + Drivers []StatsDriver `json:"drivers"` + Laps []StatsLap `json:"laps"` + Results []StatsRaceResult `json:"results"` + TSMs int64 `json:"ts_ms"` +} + +// StatsRequest is sent by clients to request a stats snapshot. +// Scope: "server" | "driver" | "race". +type StatsRequest struct { + Scope string `json:"scope"` + TargetID string `json:"target_id,omitempty"` +} + +// RaceStanding is one row in mid-race standings. +type RaceStanding struct { + Position int `json:"position"` + CarID string `json:"car_id"` + DriverID string `json:"driver_id"` + DriverName string `json:"driver_name"` + Lap int `json:"lap"` + Sector int `json:"sector"` + BestLapMs int64 `json:"best_lap_ms"` + LastLapMs int64 `json:"last_lap_ms"` + GapMs int64 `json:"gap_ms"` + DNF bool `json:"dnf"` +} + +// RaceStandingsMsg is broadcast periodically during a race. +type RaceStandingsMsg struct { + RaceID string `json:"race_id"` + Tick uint64 `json:"tick"` + TSMs int64 `json:"ts_ms"` + Lap int `json:"lap"` // leader lap + Elapsed float64 `json:"elapsed_s"` + Rows []RaceStanding `json:"rows"` +} + +// LapRecordedMsg is sent when a driver completes a lap. +type LapRecordedMsg struct { + RaceID string `json:"race_id"` + DriverID string `json:"driver_id"` + Lap int `json:"lap"` + LapMs int64 `json:"lap_ms"` + Sector1Ms int64 `json:"sector1_ms"` + Sector2Ms int64 `json:"sector2_ms"` + Sector3Ms int64 `json:"sector3_ms"` + TSMs int64 `json:"ts_ms"` + Position int `json:"position,omitempty"` +} + +// RaceResultMsg is sent for each driver when a race finishes. +type RaceResultMsg struct { + RaceID string `json:"race_id"` + DriverID string `json:"driver_id"` + Position int `json:"position"` + TotalLaps int `json:"total_laps"` + BestLapMs int64 `json:"best_lap_ms"` + TotalTimeMs int64 `json:"total_time_ms"` + DNF bool `json:"dnf"` + FinishedAtMs int64 `json:"finished_at_ms"` +} + +// Encode marshals an envelope to JSON bytes. +func Encode(env *Envelope) ([]byte, error) { + if env.TSMs == 0 { + env.TSMs = time.Now().UnixMilli() + } + return json.Marshal(env) +} + +// Decode unmarshals JSON bytes into an envelope (payload kept as json.RawMessage). +func Decode(data []byte) (*Envelope, error) { + var env Envelope + if err := json.Unmarshal(data, &env); err != nil { + return nil, fmt.Errorf("decode envelope: %w", err) + } + return &env, nil +} diff --git a/server/proto/buf.gen.yaml b/server/proto/buf.gen.yaml new file mode 100644 index 0000000..0bf5266 --- /dev/null +++ b/server/proto/buf.gen.yaml @@ -0,0 +1,10 @@ +version: v2 +plugins: + - local: protoc-gen-go + out: gen + opt: + - paths=source_relative + - local: protoc-gen-go-grpc + out: gen + opt: + - paths=source_relative diff --git a/server/proto/buf.yaml b/server/proto/buf.yaml new file mode 100644 index 0000000..f74da98 --- /dev/null +++ b/server/proto/buf.yaml @@ -0,0 +1,9 @@ +version: v2 +modules: + - path: . +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/server/proto/client_server.proto b/server/proto/client_server.proto new file mode 100644 index 0000000..2442fec --- /dev/null +++ b/server/proto/client_server.proto @@ -0,0 +1,145 @@ +syntax = "proto3"; + +package x0gp.v1; + +option go_package = "github.com/x0gp/server/proto/gen/clientserverpb"; + +// ============================================================ +// Wire protocol between client (browser / mobile) and server. +// ============================================================ +// +// In PoC we use JSON via transport/codec.go. This file is the formal spec +// for the binary protobuf wire format we'll switch to in MVP/prod. The +// generated code lives in proto/gen/ and will replace transport/. +// +// Packet envelope (oneof payload): +// message Packet { +// uint64 seq = 1; +// int64 ts_ms = 2; +// oneof payload { +// ClientHello client_hello = 10; +// ServerHello server_hello = 11; +// JoinRace join_race = 12; +// LeaveRace leave_race = 13; +// InputState input_state = 14; +// RaceSnapshot race_snapshot = 15; +// InputAck input_ack = 16; +// Correction correction = 17; +// RaceEvent race_event = 18; +// Ping ping = 19; +// Pong pong = 20; +// ErrorMsg error_msg = 21; +// ChatMessage chat_message = 22; +// } +// } + +// --- Client -> Server --- + +message ClientHello { + string version = 1; + string token = 2; // JWT in prod; empty in dev + string locale = 3; + string client_id = 4; // for reconnect +} + +message JoinRace { + string race_id = 1; + int32 car_slot = 2; // 0..3 + string car_name = 3; +} + +message LeaveRace { + string race_id = 1; +} + +// High-rate control packet (60-120 Hz). +message InputState { + float steering = 1; // -1.0 .. 1.0 + float throttle = 2; // 0.0 .. 1.0 + float brake = 3; // 0.0 .. 1.0 + int32 gear = 4; // -1 reverse, 0 neutral, 1..6 forward + uint32 buttons = 5; +} + +message Ping { + int64 client_ts_ms = 1; +} + +message ChatMessage { + string text = 1; +} + +// --- Server -> Client --- + +message ServerHello { + string session_id = 1; + uint64 race_tick = 2; + string server_version = 3; + ServerConfig config = 4; +} + +message ServerConfig { + int32 tick_rate_hz = 1; + int32 snapshot_rate_hz = 2; + int32 max_cars = 3; +} + +message RaceSnapshot { + uint64 tick = 1; + int64 ts_ms = 2; + double elapsed_s = 3; + repeated CarInfo cars = 4; +} + +message CarInfo { + string id = 1; + string driver_name = 2; + double x = 3; // metres + double y = 4; // metres + double heading = 5; // radians + double speed = 6; // m/s + int32 lap = 7; + int32 sector = 8; // 0..2 + int64 last_lap_ms = 9; + int64 best_lap_ms = 10; + AppliedInput input_applied = 11; + bool dnf = 12; +} + +message AppliedInput { + double steering = 1; + double throttle = 2; + double brake = 3; +} + +message InputAck { + uint64 seq = 1; + int64 applied_at_ms = 2; + int64 server_tick_ms = 3; +} + +message Correction { + uint64 tick = 1; + double delta_steering = 2; + double delta_throttle = 3; + string reason = 4; +} + +message RaceEvent { + string race_id = 1; + string event = 2; // "start" | "lap" | "sector" | "dnf" | "finish" | "joined" | "left" + string car_id = 3; + int32 sector = 4; + int64 lap_ms = 5; + int64 ts_ms = 6; +} + +message Pong { + int64 client_ts_ms = 1; + int64 server_ts_ms = 2; +} + +message ErrorMsg { + string code = 1; + string message = 2; +} diff --git a/server/proto/server_agent.proto b/server/proto/server_agent.proto new file mode 100644 index 0000000..4ecf90b --- /dev/null +++ b/server/proto/server_agent.proto @@ -0,0 +1,96 @@ +syntax = "proto3"; + +package x0gp.v1; + +option go_package = "github.com/x0gp/server/proto/gen/serveragentpb"; + +// ============================================================ +// Wire protocol between server (Edge / agent-linker) and the +// on-board agent (ESP32-S3 in PoC). +// ============================================================ +// +// The agent runs MicroPython (PoC) or ESP-IDF C (prod). It connects via +// Wi-Fi to the gateway-агент (in MVP) or directly to this server (in PoC) +// over WebSocket using the same envelope pattern as client_server.proto. + +// --- Server -> Agent --- + +message AgentHelloAck { + string agent_id = 1; + int32 fw_version_major = 2; + int32 fw_version_minor = 3; + ServerConfig config = 4; +} + +message ServerConfig { + int32 tick_rate_hz = 1; + int32 telemetry_rate_hz = 2; +} + +message AgentCommand { + string car_id = 1; + float steering = 2; // -1.0 .. 1.0 + float throttle = 3; // 0.0 .. 1.0 + float brake = 4; // 0.0 .. 1.0 + int32 gear = 5; + uint32 flags = 6; // bit0: E-stop, bit1: lights, ... + int64 expires_at_ms = 7; // safety: drop if older than this +} + +message AgentConfig { + string car_id = 1; + float max_steering_rate = 2; // per second + float max_throttle_rate = 3; + RampLimits ramp = 4; +} + +message RampLimits { + float throttle_up_per_s = 1; + float throttle_down_per_s = 2; + float brake_per_s = 3; +} + +// --- Agent -> Server --- + +message AgentHello { + string car_id = 1; + string fw_version = 2; + string hw_id = 3; + int32 rssi = 4; // dBm +} + +message AgentTelemetry { + string car_id = 1; + int64 ts_ms = 2; + float steering_actual = 3; + float throttle_actual = 4; + int32 rpm = 5; + float voltage_v = 6; + float current_a = 7; + float temp_esc_c = 8; + float temp_motor_c = 9; + Imu imu = 10; + IrLine ir = 11; + float battery_pct = 12; // 0..100 +} + +message Imu { + float ax = 1; float ay = 2; float az = 3; + float gx = 4; float gy = 5; float gz = 6; +} + +message IrLine { + bool front = 1; + bool rear = 2; +} + +message AgentHeartbeat { + int64 ts_ms = 1; + int64 uptime_s = 2; + repeated string errors = 3; +} + +message AgentLog { + string level = 1; // "debug" | "info" | "warn" | "error" + string msg = 2; +} diff --git a/server/scripts/genseed-json/main.go b/server/scripts/genseed-json/main.go new file mode 100644 index 0000000..e1ed3c1 --- /dev/null +++ b/server/scripts/genseed-json/main.go @@ -0,0 +1,184 @@ +// 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 + +import ( + "encoding/json" + "os" + + "github.com/x0gp/server/internal/catalog/seeddefs" +) + +// Mirrors the seeddefs package but in the JSON shape that +// scripts/genseed expects. +type waypoint struct { + X float64 `json:"x"` + Y float64 `json:"y"` + HeadingRad float64 `json:"heading_rad"` + SpeedMs float64 `json:"speed_ms"` + Curvature float64 `json:"curvature"` +} + +type bounds struct { + MinX float64 `json:"min_x"` + MinY float64 `json:"min_y"` + MaxX float64 `json:"max_x"` + MaxY float64 `json:"max_y"` + LengthM float64 `json:"length_m"` + WidthM float64 `json:"width_m"` +} + +type track struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + AuthorID string `json:"author_id"` + Visibility string `json:"visibility"` + Scale int `json:"scale"` + LengthM float64 `json:"length_m"` + WidthM float64 `json:"width_m"` + LaneWidthM float64 `json:"lane_width_m"` + Bounds bounds `json:"bounds"` + Surface string `json:"surface"` + Tags []string `json:"tags"` + Centerline []waypoint `json:"centerline"` + BestLapMs int64 `json:"best_lap_ms"` + BestLapHolder string `json:"best_lap_holder"` + CreatedMs int64 `json:"created_ms"` + UpdatedMs int64 `json:"updated_ms"` +} + +type chassis struct { + Model string `json:"model"` + Material string `json:"material"` + Printed bool `json:"printed"` + WheelbaseMm float64 `json:"wheelbase_mm"` + TrackMm float64 `json:"track_mm"` +} + +type motor struct { + Kind string `json:"kind"` + Class string `json:"class"` + KV int `json:"kv"` + PowerW float64 `json:"power_w"` +} + +type battery struct { + VoltageV float64 `json:"voltage_v"` + CapacityMah int `json:"capacity_mah"` + Cells int `json:"cells"` + Chemistry string `json:"chemistry"` +} + +type car struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerID string `json:"owner_id"` + Visibility string `json:"visibility"` + Scale int `json:"scale"` + LengthMm float64 `json:"length_mm"` + WidthMm float64 `json:"width_mm"` + HeightMm float64 `json:"height_mm"` + WeightG float64 `json:"weight_g"` + Chassis chassis `json:"chassis"` + Motor motor `json:"motor"` + Battery battery `json:"battery"` + Drive string `json:"drive"` + TopSpeedMs float64 `json:"top_speed_ms"` + ColorHex string `json:"color_hex"` + AvatarURL string `json:"avatar_url"` + Active bool `json:"active"` + CreatedMs int64 `json:"created_ms"` + UpdatedMs int64 `json:"updated_ms"` +} + +func toWps(in []seeddefs.Waypoint) []waypoint { + out := make([]waypoint, len(in)) + for i, w := range in { + out[i] = waypoint{X: w.X, Y: w.Y, HeadingRad: w.HeadingRad, SpeedMs: w.SpeedMs, Curvature: w.Curvature} + } + return out +} + +func toTrack(t seeddefs.Track) track { + return track{ + ID: t.ID, + Name: t.Name, + Description: t.Description, + AuthorID: t.AuthorID, + Visibility: t.Visibility, + Scale: t.Scale, + LengthM: t.LengthM, + WidthM: t.WidthM, + LaneWidthM: t.LaneWidthM, + Bounds: bounds{ + MinX: t.Bounds.MinX, MinY: t.Bounds.MinY, + MaxX: t.Bounds.MaxX, MaxY: t.Bounds.MaxY, + LengthM: t.Bounds.LengthM, WidthM: t.Bounds.WidthM, + }, + Surface: t.Surface, + Tags: t.Tags, + Centerline: toWps(t.Centerline), + BestLapHolder: t.BestLapHolder, + CreatedMs: 1700000000000, + UpdatedMs: 1700000000000, + } +} + +func toCar(c seeddefs.Car) car { + return car{ + ID: c.ID, + Name: c.Name, + OwnerID: c.OwnerID, + Visibility: c.Visibility, + Scale: c.Scale, + LengthMm: c.LengthMm, + WidthMm: c.WidthMm, + HeightMm: c.HeightMm, + WeightG: c.WeightG, + Chassis: chassis{ + Model: c.Chassis.Model, Material: c.Chassis.Material, + Printed: c.Chassis.Printed, WheelbaseMm: c.Chassis.WheelbaseMm, + TrackMm: c.Chassis.TrackMm, + }, + Motor: motor{ + Kind: c.Motor.Kind, Class: c.Motor.Class, + KV: c.Motor.KV, PowerW: c.Motor.PowerW, + }, + Battery: battery{ + VoltageV: c.Battery.VoltageV, CapacityMah: c.Battery.CapacityMah, + Cells: c.Battery.Cells, Chemistry: c.Battery.Chemistry, + }, + Drive: c.Drive, + TopSpeedMs: c.TopSpeedMs, + ColorHex: c.ColorHex, + Active: c.Active, + CreatedMs: 1700000000000, + UpdatedMs: 1700000000000, + } +} + +func main() { + srcTracks := seeddefs.DefaultTracks() + srcCars := seeddefs.DefaultCars() + out := struct { + Tracks []track `json:"tracks"` + Cars []car `json:"cars"` + }{ + Tracks: make([]track, len(srcTracks)), + Cars: make([]car, len(srcCars)), + } + for i, t := range srcTracks { + out.Tracks[i] = toTrack(t) + } + for i, c := range srcCars { + out.Cars[i] = toCar(c) + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(out) +} \ No newline at end of file diff --git a/server/scripts/genseed/main.go b/server/scripts/genseed/main.go new file mode 100644 index 0000000..110a861 --- /dev/null +++ b/server/scripts/genseed/main.go @@ -0,0 +1,200 @@ +// 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 + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" +) + +type waypoint struct { + X float64 `json:"x"` + Y float64 `json:"y"` + HeadingRad float64 `json:"heading_rad"` + SpeedMs float64 `json:"speed_ms"` + Curvature float64 `json:"curvature"` +} + +type bounds struct { + MinX float64 `json:"min_x"` + MinY float64 `json:"min_y"` + MaxX float64 `json:"max_x"` + MaxY float64 `json:"max_y"` + LengthM float64 `json:"length_m"` + WidthM float64 `json:"width_m"` +} + +type track struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + AuthorID string `json:"author_id"` + Visibility string `json:"visibility"` + Scale int `json:"scale"` + LengthM float64 `json:"length_m"` + WidthM float64 `json:"width_m"` + LaneWidthM float64 `json:"lane_width_m"` + Bounds bounds `json:"bounds"` + Surface string `json:"surface"` + Tags []string `json:"tags"` + Centerline []waypoint `json:"centerline"` + BestLapMs int64 `json:"best_lap_ms"` + BestLapHolder string `json:"best_lap_holder"` + CreatedMs int64 `json:"created_ms"` + UpdatedMs int64 `json:"updated_ms"` +} + +type chassis struct { + Model string `json:"model"` + Material string `json:"material"` + Printed bool `json:"printed"` + WheelbaseMm float64 `json:"wheelbase_mm"` + TrackMm float64 `json:"track_mm"` +} + +type motor struct { + Kind string `json:"kind"` + Class string `json:"class"` + KV int `json:"kv"` + PowerW float64 `json:"power_w"` +} + +type battery struct { + VoltageV float64 `json:"voltage_v"` + CapacityMah int `json:"capacity_mah"` + Cells int `json:"cells"` + Chemistry string `json:"chemistry"` +} + +type car struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerID string `json:"owner_id"` + Visibility string `json:"visibility"` + Scale int `json:"scale"` + LengthMm float64 `json:"length_mm"` + WidthMm float64 `json:"width_mm"` + HeightMm float64 `json:"height_mm"` + WeightG float64 `json:"weight_g"` + Chassis chassis `json:"chassis"` + Motor motor `json:"motor"` + Battery battery `json:"battery"` + Drive string `json:"drive"` + TopSpeedMs float64 `json:"top_speed_ms"` + ColorHex string `json:"color_hex"` + AvatarURL string `json:"avatar_url"` + Active bool `json:"active"` + CreatedMs int64 `json:"created_ms"` + UpdatedMs int64 `json:"updated_ms"` +} + +type dump struct { + Tracks []track `json:"tracks"` + Cars []car `json:"cars"` +} + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: genseed ") + os.Exit(2) + } + f, err := os.Open(os.Args[1]) + if err != nil { + panic(err) + } + defer f.Close() + var d dump + if err := json.NewDecoder(f).Decode(&d); err != nil { + panic(err) + } + + var b strings.Builder + b.WriteString("-- 002_seed.sql — initial system catalog (tracks + cars).\n") + b.WriteString("-- Generated from internal/catalog/seeddefs via scripts/genseed.\n") + b.WriteString("-- Idempotent: safe to re-apply (ON CONFLICT DO NOTHING).\n\n") + + for _, t := range d.Tracks { + fmt.Fprintf(&b, "INSERT INTO tracks (id, name, description, author_id, visibility, scale,\n") + fmt.Fprintf(&b, " length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,\n") + fmt.Fprintf(&b, " bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,\n") + fmt.Fprintf(&b, " created_ms, updated_ms)\n") + fmt.Fprintf(&b, "VALUES (%s, %s, %s, %s, %s, %d,\n", + sqlStr(t.ID), sqlStr(t.Name), sqlStr(t.Description), sqlStr(t.AuthorID), + sqlStr(t.Visibility), t.Scale) + fmt.Fprintf(&b, " %s, %s, %s, %s, %d, %s,\n", + sqlF(t.LengthM), sqlF(t.WidthM), sqlF(t.LaneWidthM), sqlStr(t.Surface), + t.BestLapMs, sqlStr(t.BestLapHolder)) + fmt.Fprintf(&b, " %s, %s, %s, %s,\n", + sqlF(t.Bounds.MinX), sqlF(t.Bounds.MinY), sqlF(t.Bounds.MaxX), sqlF(t.Bounds.MaxY)) + fmt.Fprintf(&b, " %d, %d)\n", t.CreatedMs, t.UpdatedMs) + b.WriteString("ON CONFLICT (id) DO NOTHING;\n\n") + + for i, wp := range t.Centerline { + fmt.Fprintf(&b, "INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)\n") + fmt.Fprintf(&b, "VALUES (%s, %d, %s, %s, %s, %s, %s)\n", + sqlStr(t.ID), i, + sqlF(wp.X), sqlF(wp.Y), sqlF(wp.HeadingRad), sqlF(wp.SpeedMs), sqlF(wp.Curvature)) + b.WriteString("ON CONFLICT (track_id, seq) DO NOTHING;\n") + } + b.WriteString("\n") + + for _, tag := range t.Tags { + fmt.Fprintf(&b, "INSERT INTO track_tags (track_id, tag) VALUES (%s, %s)\n", + sqlStr(t.ID), sqlStr(tag)) + b.WriteString("ON CONFLICT DO NOTHING;\n") + } + b.WriteString("\n") + } + + for _, c := range d.Cars { + fmt.Fprintf(&b, "INSERT INTO cars (id, name, owner_id, visibility, scale,\n") + fmt.Fprintf(&b, " length_mm, width_mm, height_mm, weight_g,\n") + fmt.Fprintf(&b, " chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,\n") + fmt.Fprintf(&b, " motor_kind, motor_class, motor_kv, motor_power_w,\n") + fmt.Fprintf(&b, " battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,\n") + fmt.Fprintf(&b, " drive, top_speed_ms, color_hex, avatar_url, active,\n") + fmt.Fprintf(&b, " created_ms, updated_ms)\n") + fmt.Fprintf(&b, "VALUES (%s, %s, %s, %s, %d,\n", + sqlStr(c.ID), sqlStr(c.Name), sqlStr(c.OwnerID), sqlStr(c.Visibility), c.Scale) + fmt.Fprintf(&b, " %s, %s, %s, %s,\n", + sqlF(c.LengthMm), sqlF(c.WidthMm), sqlF(c.HeightMm), sqlF(c.WeightG)) + fmt.Fprintf(&b, " %s, %s, %s, %s, %s,\n", + sqlStr(c.Chassis.Model), sqlStr(c.Chassis.Material), sqlBool(c.Chassis.Printed), + sqlF(c.Chassis.WheelbaseMm), sqlF(c.Chassis.TrackMm)) + fmt.Fprintf(&b, " %s, %s, %d, %s,\n", + sqlStr(c.Motor.Kind), sqlStr(c.Motor.Class), c.Motor.KV, sqlF(c.Motor.PowerW)) + fmt.Fprintf(&b, " %s, %d, %d, %s,\n", + sqlF(c.Battery.VoltageV), c.Battery.CapacityMah, c.Battery.Cells, sqlStr(c.Battery.Chemistry)) + fmt.Fprintf(&b, " %s, %s, %s, %s, %s,\n", + sqlStr(c.Drive), sqlF(c.TopSpeedMs), sqlStr(c.ColorHex), sqlStr(c.AvatarURL), sqlBool(c.Active)) + fmt.Fprintf(&b, " %d, %d)\n", c.CreatedMs, c.UpdatedMs) + b.WriteString("ON CONFLICT (id) DO NOTHING;\n\n") + } + + os.Stdout.WriteString(b.String()) +} + +func sqlStr(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +func sqlBool(b bool) string { + if b { + return "TRUE" + } + return "FALSE" +} + +func sqlF(f float64) string { + return strconv.FormatFloat(f, 'f', -1, 64) +} \ No newline at end of file diff --git a/server/smoke_ws.go b/server/smoke_ws.go new file mode 100644 index 0000000..81a323d --- /dev/null +++ b/server/smoke_ws.go @@ -0,0 +1,96 @@ +//go:build ignore +// +build ignore + +// Smoke test: ws client connects, exchanges a few messages, exits. +// Run: go run ./smoke_test.go +package main + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "time" + + "github.com/gorilla/websocket" +) + +func main() { + u := url.URL{Scheme: "ws", Host: "localhost:8080", Path: "/ws"} + ws, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + fmt.Println("dial:", err) + os.Exit(1) + } + defer ws.Close() + + // channel reader + go func() { + for { + _, msg, err := ws.ReadMessage() + if err != nil { + fmt.Println("read err:", err) + return + } + var env struct { + Type string `json:"type"` + Seq uint64 `json:"seq"` + TSMs int64 `json:"ts_ms"` + Payload json.RawMessage `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + fmt.Println("json err:", err) + continue + } + if env.Type == "race_snapshot" { + fmt.Printf("[rx] %s tick=%d cars=%d ts=%d\n", env.Type, 0, countCars(env.Payload), env.TSMs) + } else { + fmt.Printf("[rx] %s seq=%d payload=%s\n", env.Type, env.Seq, string(env.Payload)) + } + } + }() + + send := func(t string, p map[string]any) { + env := map[string]any{ + "type": t, + "seq": 1, + "ts_ms": time.Now().UnixMilli(), + "payload": p, + } + b, _ := json.Marshal(env) + if err := ws.WriteMessage(websocket.TextMessage, b); err != nil { + fmt.Println("write err:", err) + } + fmt.Println("[tx]", t) + } + + send("client_hello", map[string]any{ + "version": "x0gp/0.1", "token": "", "locale": "ru", + }) + time.Sleep(200 * time.Millisecond) + send("join_race", map[string]any{ + "race_id": "demo-race", "car_slot": 0, "car_name": "smoke-test", + }) + time.Sleep(200 * time.Millisecond) + for i := 0; i < 30; i++ { + send("input_state", map[string]any{ + "steering": 0.5, "throttle": 1.0, "brake": 0.0, "gear": 1, + }) + time.Sleep(33 * time.Millisecond) + } + send("ping", map[string]any{"client_ts_ms": time.Now().UnixMilli()}) + time.Sleep(200 * time.Millisecond) + send("leave_race", map[string]any{}) + + // give server time to push last snapshots + time.Sleep(500 * time.Millisecond) + fmt.Println("done") +} + +func countCars(raw json.RawMessage) int { + var s struct { + Cars []map[string]any `json:"cars"` + } + _ = json.Unmarshal(raw, &s) + return len(s.Cars) +} \ No newline at end of file diff --git a/server/web/README.md b/server/web/README.md new file mode 100644 index 0000000..b22281e --- /dev/null +++ b/server/web/README.md @@ -0,0 +1,83 @@ +# x0gp · PoC web client + +Минимальный vanilla JS клиент для проверки PoC-сервера из этой же папки. +Без сборки, без зависимостей — открывается в браузере. + +## Запуск + +Вариант А — открыть файл напрямую: + +```bash +# Windows +start web/index.html + +# Linux +xdg-open web/index.html + +# macOS +open web/index.html +``` + +> Некоторые браузеры не разрешают `WebSocket` к чужим origin со страницы, +> открытой по `file://`. Если что — поднимите мини-сервер (вариант Б). + +Вариант Б — раздать через тот же poc-server: + +В `cmd/poc-server/main.go` есть TODO — добавить `http.FileServer` для `web/`. +После этого клиент будет доступен по `http://localhost:8080/`. + +Вариант В — `python -m http.server`: + +```bash +cd server/web +python -m http.server 5173 +# открыть http://localhost:5173/ +``` + +## Подключение к серверу + +1. Запустите `poc-server` (см. корневой `server/README.md`). +2. В UI клиента проверьте `WS URL` — по умолчанию `ws://localhost:8080/ws`. +3. Укажите `driver_id` (например `driver-1`). +4. **Connect** → **Join race**. +5. Управление: + - `W` / `↑` — газ + - `S` / `↓` — тормоз + - `A` / `←` — руль влево + - `D` / `→` — руль вправо + - `Space` — ручник +6. На canvas появится ваша машинка (синяя) и машинки других подключённых + драйверов (оранжевые). + +## Что смотреть + +- **tick Hz** в верхней панели — частота приходящих `race_snapshot`. + Должно держаться ~30 Hz. +- **rtt** — round-trip ping/pong, в PoC без сети это 0–2 ms. +- **seq** — растёт с каждым отправленным сообщением. +- **Телеметрия** — speed/heading/lap/sector вашей машинки. +- **Лог** — все входящие/исходящие сообщения (кроме ping/pong). + +## Что PoC делает + +- Открывает WebSocket, шлёт `client_hello`. +- По кнопке— `join_race` / `leave_race`. +- Каждые 33 ms (≈30 Hz) шлёт `input_state` (throttle/brake/steer/handbrake). +- Каждые 1000 ms шлёт `ping` для измерения RTT. +- Получает `race_snapshot` и рисует все машинки на канвасе. +- Получает `input_ack`, `race_event`, `correction`, `error` и логирует их. + +## Что PoC НЕ делает + +- Нет предиктивного сглаживания (interpolation/extrapolation) — увидите + «дёрганье», это нормально для PoC. +- Нет клиент-сайд предикции — добавим, когда появится настоящая модель физики. +- Нет тач-управления для мобильных — клавиатура только. +- Нет авторизации, биллинга, чата — это всё вне PoC. + +## Что дальше + +- Когда `proto/` будет сгенерирован в Go и JS — заменим JSON на protobuf. +- Подключим `gRPC-Web` или бинарный WS-фрейминг. +- Добавим предиктивный слой и интерполяцию. +- Добавим мобильный layout (touch steering + pedals). \ No newline at end of file diff --git a/server/web/index.html b/server/web/index.html new file mode 100644 index 0000000..b70cfb5 --- /dev/null +++ b/server/web/index.html @@ -0,0 +1,106 @@ + + + + + + x0gp · PoC client + + + +
+
+ + PoC client +
+
disconnected
+
+ tick: -- Hz + rtt: -- ms + seq: 0 +
+
+ +
+ +
+ +
+ вы + другие + correction (если будет) +
+
+ + + +
+ + + + \ No newline at end of file diff --git a/server/web/main.js b/server/web/main.js new file mode 100644 index 0000000..acb2dcb --- /dev/null +++ b/server/web/main.js @@ -0,0 +1,468 @@ +// x0gp · PoC web client (vanilla JS) +// Протокол совпадает с internal/transport/codec.go (envelope + payload). +// В PoC сервер говорит по JSON — потом заменим на binary protobuf. + +(function () { + 'use strict'; + + // -------- DOM -------- + const $ = (id) => document.getElementById(id); + const ui = { + wsUrl: $('wsUrl'), + driverId: $('driverId'), + raceId: $('raceId'), + btnConnect: $('btnConnect'), + btnDisconnect: $('btnDisconnect'), + btnJoin: $('btnJoin'), + btnLeave: $('btnLeave'), + connState: $('connState'), + tickHz: $('tickHz'), + rtt: $('rtt'), + seq: $('seq'), + canvas: $('track'), + log: $('log'), + barThrottle: $('barThrottle'), + barBrake: $('barBrake'), + barSteer: $('barSteer'), + lblThrottle: $('lblThrottle'), + lblBrake: $('lblBrake'), + lblSteer: $('lblSteer'), + wheelDisc: $('wheelDisc'), + tSpeed: $('tSpeed'), + tHeading: $('tHeading'), + tLap: $('tLap'), + tSector: $('tSector'), + tPos: $('tPos'), + tTsm: $('tTsm'), + tCars: $('tCars'), + }; + + // -------- State -------- + const state = { + ws: null, + connected: false, + joined: false, + mySessionId: null, + myCarId: null, + inputSeq: 0, + inputSendAt: 0, + lastPingAt: 0, + lastPongRtt: 0, + lastSnapshot: null, + lastSnapshotAt: 0, + snapshotCount: 0, + snapshotWindow: [], + keys: new Set(), + input: { throttle: 0, brake: 0, steer: 0, handbrake: false, buttons: 0 }, + pingTimer: null, + inputTimer: null, + slotHint: 0, // сервер выдаёт слот; 0 по умолчанию + }; + + const ctx = ui.canvas.getContext('2d'); + const CANVAS_W = ui.canvas.width; + const CANVAS_H = ui.canvas.height; + // Простая кольцевая трасса для PoC (центр в (CANVAS_W/2, CANVAS_H/2)) + const TRACK = { + cx: CANVAS_W / 2, + cy: CANVAS_H / 2, + rx: 260, // внешний радиус по X + ry: 150, // внешний радиус по Y + lane: 40, // ширина трассы в пикселях + }; + // Коэффициент пересчёта: 1 метр = ~ 20 пикселей (для PoC) + const M2PX = 20; + + // -------- Logging -------- + function log(msg, kind) { + const t = new Date().toLocaleTimeString(); + const line = document.createElement('div'); + line.className = 'l-' + (kind || 'info'); + line.textContent = `[${t}] ${msg}`; + ui.log.appendChild(line); + ui.log.scrollTop = ui.log.scrollHeight; + // обрезаем лог до 500 строк + while (ui.log.childNodes.length > 500) ui.log.removeChild(ui.log.firstChild); + } + + // -------- Helpers -------- + function setConn(text, cls) { + ui.connState.textContent = text; + ui.connState.className = 'conn ' + (cls || ''); + } + function clamp(v, a, b) { return v < a ? a : v > b ? b : v; } + function deadzone(v, d) { return Math.abs(v) < d ? 0 : v; } + + // -------- Input handling (keyboard) -------- + // W/S — газ/тормоз; A/D — руль; Space — ручник; R — reset (debug) + function pollInput() { + const k = state.keys; + // Throttle & brake — дискретные «клавиши», но мы шлём 1.0 при нажатой. + // Для плавности можно отдавать ramp-rate — в PoC держим 0/1. + const throttle = (k.has('w') || k.has('arrowup')) ? 1.0 : 0.0; + const brake = (k.has('s') || k.has('arrowdown')) ? 1.0 : 0.0; + // Steer — это «удержание» крайнего положения. 0 если ничего не нажато. + let steer = 0.0; + if (k.has('a') || k.has('arrowleft')) steer -= 1.0; + if (k.has('d') || k.has('arrowright')) steer += 1.0; + steer = clamp(steer, -1, 1); + const handbrake = k.has(' '); + + state.input.throttle = throttle; + state.input.brake = brake; + state.input.steer = steer; + state.input.handbrake = handbrake; + + // Update UI bars + ui.lblThrottle.textContent = throttle.toFixed(2); + ui.lblBrake.textContent = brake.toFixed(2); + ui.lblSteer.textContent = steer.toFixed(2); + + // bars: позитивная шкала; для руля — симметричная вокруг центра + ui.barThrottle.style.left = '0%'; + ui.barThrottle.style.width = (throttle * 100).toFixed(1) + '%'; + ui.barBrake.style.left = '0%'; + ui.barBrake.style.width = (brake * 100).toFixed(1) + '%'; + // руль: центр = 50% + const steerPct = (steer + 1) * 50; + ui.barSteer.style.left = (steerPct - 1) + '%'; + ui.barSteer.style.width = '2%'; + + ui.wheelDisc.style.transform = `rotate(${steer * 90}deg)`; + } + + // -------- Send helpers -------- + function sendEnvelope(env) { + if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return; + const data = JSON.stringify(env); + try { + state.ws.send(data); + if (env.type !== 'ping') log('→ ' + env.type, 'tx'); + } catch (e) { + log('send error: ' + e.message, 'error'); + } + } + + function sendClientHello() { + sendEnvelope({ + type: 'client_hello', + seq: ++state.inputSeq, + tsMs: Date.now(), + payload: { + version: 'x0gp/0.1', + token: '', + locale: (navigator.language || 'en').slice(0, 8), + client_id: '', + }, + }); + } + + function sendJoinRace() { + const name = ui.driverId.value.trim() || 'driver-anon'; + sendEnvelope({ + type: 'join_race', + seq: ++state.inputSeq, + tsMs: Date.now(), + payload: { + race_id: ui.raceId.value.trim() || 'demo-race', + car_slot: state.slotHint, + car_name: name, + }, + }); + } + + function sendLeaveRace() { + sendEnvelope({ + type: 'leave_race', + seq: ++state.inputSeq, + tsMs: Date.now(), + payload: {}, + }); + } + + function sendInputState() { + if (!state.joined) return; + state.inputSendAt = performance.now(); + sendEnvelope({ + type: 'input_state', + seq: ++state.inputSeq, + tsMs: Date.now(), + payload: { + steering: state.input.steer, + throttle: state.input.throttle, + brake: state.input.brake, + gear: 1, + buttons: state.input.buttons, + }, + }); + } + + function sendPing() { + state.lastPingAt = performance.now(); + sendEnvelope({ + type: 'ping', + seq: ++state.inputSeq, + tsMs: Date.now(), + payload: { + client_ts_ms: Date.now(), + }, + }); + } + + // -------- Receive -------- + function handleServerEnvelope(env) { + switch (env.type) { + case 'server_hello': { + const p = env.payload || {}; + state.mySessionId = p.session_id || p.sessionId; + const cfg = p.config || {}; + log('server hello: session=' + state.mySessionId + + ' tick=' + (cfg.tick_rate_hz || '?') + 'Hz' + + ' snapshot=' + (cfg.snapshot_rate_hz || '?') + 'Hz', 'meta'); + break; + } + case 'race_snapshot': { + handleSnapshot(env.payload); + break; + } + case 'input_ack': { + // сервер подтверждает вход по seq; можно использовать для RTT-оценки + const seq = env.seq; + log('input_ack seq=' + seq, 'rx'); + break; + } + case 'pong': { + const now = performance.now(); + state.lastPongRtt = Math.round(now - state.lastPingAt); + ui.rtt.textContent = String(state.lastPongRtt); + break; + } + case 'race_event': { + const p = env.payload || {}; + log('event: ' + (p.eventType || '?') + ' ' + JSON.stringify(p), 'warn'); + break; + } + case 'error': { + log('server error: ' + JSON.stringify(env.payload), 'error'); + break; + } + case 'correction': { + log('correction: ' + JSON.stringify(env.payload), 'warn'); + break; + } + default: + log('unknown type: ' + env.type, 'warn'); + } + } + + function handleSnapshot(payload) { + if (!payload) return; + state.lastSnapshot = payload; + state.lastSnapshotAt = performance.now(); + state.snapshotCount++; + const now = performance.now(); + state.snapshotWindow.push(now); + while (state.snapshotWindow.length && now - state.snapshotWindow[0] > 1000) { + state.snapshotWindow.shift(); + } + ui.tickHz.textContent = String(state.snapshotWindow.length); + ui.seq.textContent = String(env_seq_current()); + + ui.tTsm.textContent = String(payload.ts_ms || '--'); + ui.tCars.textContent = String((payload.cars || []).length); + + // Найти свою машинку по driver_name (== driverId из формы) + const myName = ui.driverId.value.trim(); + const me = (payload.cars || []).find((c) => c.driver_name === myName); + if (me) { + state.myCarId = me.id; + ui.tSpeed.textContent = (me.speed || 0).toFixed(2) + ' m/s'; + ui.tHeading.textContent = ((me.heading || 0) * 180 / Math.PI).toFixed(1) + '°'; + ui.tLap.textContent = String(me.lap || 0); + ui.tSector.textContent = String(me.sector != null ? me.sector : '-'); + ui.tPos.textContent = `(${(me.x || 0).toFixed(2)}, ${(me.y || 0).toFixed(2)})`; + } + + drawSnapshot(payload); + } + + function env_seq_current() { return state.inputSeq; } + + // -------- Rendering -------- + function drawSnapshot(snap) { + ctx.clearRect(0, 0, CANVAS_W, CANVAS_H); + + // фон-сетка + ctx.strokeStyle = '#1f2630'; + ctx.lineWidth = 1; + for (let x = 0; x < CANVAS_W; x += 40) { + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, CANVAS_H); ctx.stroke(); + } + for (let y = 0; y < CANVAS_H; y += 40) { + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(CANVAS_W, y); ctx.stroke(); + } + + // Кольцевая трасса (овал). Внешний радиус = rx, ry; внутренний = rx-lane, ry-lane. + ctx.lineWidth = TRACK.lane; + ctx.strokeStyle = '#2d333b'; + ctx.beginPath(); + ctx.ellipse(TRACK.cx, TRACK.cy, TRACK.rx, TRACK.ry, 0, 0, Math.PI * 2); + ctx.stroke(); + + // Центральная разметка + ctx.lineWidth = 2; + ctx.strokeStyle = '#484f58'; + ctx.setLineDash([8, 8]); + ctx.beginPath(); + ctx.ellipse(TRACK.cx, TRACK.cy, + TRACK.rx - TRACK.lane / 2, TRACK.ry - TRACK.lane / 2, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + + // Стартовая линия + ctx.strokeStyle = '#f0883e'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(TRACK.cx, TRACK.cy - TRACK.ry); + ctx.lineTo(TRACK.cx, TRACK.cy - TRACK.ry + TRACK.lane); + ctx.stroke(); + + // Машинки. Мировые координаты (метры) → пиксели: + // центр трассы в (0,0), X — вправо, Y — вниз (как на канвасе). + // В PoC считаем, что 1 метр = M2PX пикселей, начало трассы в (0,0). + // TODO: после появления настоящей карты — масштабировать под неё. + const myDriver = ui.driverId.value.trim(); + for (const car of (snap.cars || [])) { + const isMe = car.driver_name === myDriver; + const px = TRACK.cx + (car.x || 0) * M2PX; + const py = TRACK.cy + (car.y || 0) * M2PX; + ctx.save(); + ctx.translate(px, py); + ctx.rotate(car.heading || 0); + ctx.fillStyle = isMe ? '#58a6ff' : '#f0883e'; + ctx.strokeStyle = '#0e1117'; + ctx.lineWidth = 1; + // корпус машинки + ctx.fillRect(-10, -5, 20, 10); + ctx.strokeRect(-10, -5, 20, 10); + // направляющая полоска + ctx.fillStyle = isMe ? '#a5c6ff' : '#ffc685'; + ctx.fillRect(8, -1, 4, 2); + ctx.restore(); + + // метка driver_name + ctx.fillStyle = '#e6edf3'; + ctx.font = '11px ui-monospace, Menlo, Consolas, monospace'; + ctx.textAlign = 'center'; + ctx.fillText((car.driver_name || '?') + (isMe ? ' ★' : ''), px, py - 14); + } + } + + // -------- Connection -------- + function connect() { + const url = ui.wsUrl.value.trim(); + log('connecting to ' + url, 'info'); + setConn('connecting…', 'connecting'); + const ws = new WebSocket(url); + state.ws = ws; + + ws.addEventListener('open', () => { + state.connected = true; + setConn('connected', 'connected'); + log('ws open', 'info'); + ui.btnConnect.disabled = true; + ui.btnDisconnect.disabled = false; + ui.btnJoin.disabled = false; + sendClientHello(); + + // Timers: input 30Hz, ping 1Hz + if (state.inputTimer) clearInterval(state.inputTimer); + state.inputTimer = setInterval(() => { + pollInput(); + sendInputState(); + }, 33); // ~30 Hz (snapshots — 30 Hz, чтобы не заливать канал) + + if (state.pingTimer) clearInterval(state.pingTimer); + state.pingTimer = setInterval(sendPing, 1000); + }); + + ws.addEventListener('message', (ev) => { + let env; + try { env = JSON.parse(ev.data); } + catch (e) { log('bad json: ' + e.message, 'error'); return; } + if (env.type !== 'pong') log('← ' + env.type, 'rx'); + handleServerEnvelope(env); + }); + + ws.addEventListener('close', (ev) => { + state.connected = false; + state.joined = false; + setConn('disconnected', ''); + log('ws close code=' + ev.code + ' reason=' + (ev.reason || ''), 'warn'); + ui.btnConnect.disabled = false; + ui.btnDisconnect.disabled = true; + ui.btnJoin.disabled = true; + ui.btnLeave.disabled = true; + if (state.inputTimer) { clearInterval(state.inputTimer); state.inputTimer = null; } + if (state.pingTimer) { clearInterval(state.pingTimer); state.pingTimer = null; } + }); + + ws.addEventListener('error', (ev) => { + log('ws error', 'error'); + setConn('error', 'error'); + }); + } + + function disconnect() { + if (state.ws) state.ws.close(1000, 'client-bye'); + } + + function joinRace() { + if (!state.connected) return; + state.joined = true; + ui.btnJoin.disabled = true; + ui.btnLeave.disabled = false; + sendJoinRace(); + } + + function leaveRace() { + if (!state.connected) return; + state.joined = false; + ui.btnJoin.disabled = false; + ui.btnLeave.disabled = true; + sendLeaveRace(); + } + + // -------- Keyboard -------- + function keyName(e) { + if (e.key === ' ') return ' '; + if (e.key === 'ArrowUp') return 'arrowup'; + if (e.key === 'ArrowDown') return 'arrowdown'; + if (e.key === 'ArrowLeft') return 'arrowleft'; + if (e.key === 'ArrowRight') return 'arrowright'; + return e.key.toLowerCase(); + } + + window.addEventListener('keydown', (e) => { + const k = keyName(e); + // Не даём странице скроллить на стрелках/пробеле, если фокус на инпутах — пропускаем. + const tag = (e.target && e.target.tagName) || ''; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + if (['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k)) e.preventDefault(); + state.keys.add(k); + }); + window.addEventListener('keyup', (e) => { + state.keys.delete(keyName(e)); + }); + window.addEventListener('blur', () => state.keys.clear()); + + // -------- Buttons -------- + ui.btnConnect.addEventListener('click', connect); + ui.btnDisconnect.addEventListener('click', disconnect); + ui.btnJoin.addEventListener('click', joinRace); + ui.btnLeave.addEventListener('click', leaveRace); + + // -------- Boot -------- + setConn('disconnected', ''); + log('x0gp PoC client ready. Press Connect.', 'meta'); +})(); \ No newline at end of file diff --git a/server/web/style.css b/server/web/style.css new file mode 100644 index 0000000..edf1042 --- /dev/null +++ b/server/web/style.css @@ -0,0 +1,267 @@ +/* x0gp · PoC web client stylesheet + Минималистичный тёмный UI. Без зависимостей, без сборки. */ + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; height: 100%; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, + Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif; + background: #0e1117; + color: #e6edf3; + font-size: 14px; + line-height: 1.4; +} + +.topbar { + display: flex; + align-items: center; + gap: 18px; + padding: 10px 16px; + background: #161b22; + border-bottom: 1px solid #30363d; +} +.brand .logo { + font-weight: 700; + letter-spacing: 0.5px; + color: #58a6ff; + font-size: 18px; +} +.brand .tag { + margin-left: 8px; + padding: 2px 6px; + font-size: 11px; + background: #1f6feb; + color: #fff; + border-radius: 4px; + text-transform: uppercase; +} +.conn { + padding: 4px 10px; + border-radius: 4px; + background: #6e7681; + color: #fff; + font-size: 12px; +} +.conn.connected { background: #2ea043; } +.conn.connecting { background: #d29922; } +.conn.error { background: #f85149; } + +.hud-top { + margin-left: auto; + display: flex; + gap: 14px; + font-size: 12px; + color: #8b949e; +} +.hud-top b { color: #e6edf3; font-weight: 600; } + +.layout { + display: grid; + grid-template-columns: 1fr 360px; + gap: 16px; + padding: 16px; + align-items: start; +} + +/* Track canvas */ +.track-wrap { + background: #161b22; + border: 1px solid #30363d; + border-radius: 8px; + padding: 12px; +} +.track-wrap canvas { + width: 100%; + height: auto; + display: block; + background: #0a0d12; + border-radius: 4px; + image-rendering: pixelated; +} +.legend { + display: flex; + gap: 16px; + margin-top: 8px; + font-size: 12px; + color: #8b949e; +} +.legend i.dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 4px; + vertical-align: middle; +} +.dot.me { background: #58a6ff; } +.dot.other { background: #f0883e; } +.dot.ghost { background: #d29922; } + +/* Controls aside */ +.controls { display: flex; flex-direction: column; gap: 12px; } +.card { + background: #161b22; + border: 1px solid #30363d; + border-radius: 8px; + padding: 12px; +} +.card h3 { + margin: 0 0 10px; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #8b949e; +} +.card label { + display: block; + font-size: 12px; + color: #8b949e; + margin-bottom: 8px; +} +.card input[type=text] { + display: block; + width: 100%; + margin-top: 4px; + padding: 6px 8px; + background: #0e1117; + border: 1px solid #30363d; + border-radius: 4px; + color: #e6edf3; + font-family: inherit; + font-size: 13px; +} +.card input[type=text]:focus { + outline: none; + border-color: #58a6ff; +} + +.btn-row { + display: flex; + gap: 6px; + margin-top: 8px; +} +.btn { + flex: 1; + padding: 6px 10px; + background: #21262d; + border: 1px solid #30363d; + border-radius: 4px; + color: #e6edf3; + cursor: pointer; + font-size: 12px; + font-family: inherit; +} +.btn:hover:not(:disabled) { background: #30363d; } +.btn:disabled { opacity: 0.4; cursor: not-allowed; } +.btn.primary { background: #238636; border-color: #2ea043; } +.btn.primary:hover:not(:disabled) { background: #2ea043; } + +.hint { + margin: 0 0 8px; + font-size: 11px; + color: #8b949e; +} + +/* Pedals / bars */ +.pedals { display: flex; flex-direction: column; gap: 6px; } +.bar { + display: grid; + grid-template-columns: 60px 1fr 44px; + gap: 8px; + align-items: center; + font-size: 12px; +} +.bar-track { + position: relative; + height: 12px; + background: #0e1117; + border: 1px solid #30363d; + border-radius: 3px; + overflow: hidden; +} +.bar-fill { + position: absolute; + top: 0; bottom: 0; + left: 50%; + width: 0%; + transition: left 60ms linear, width 60ms linear; +} +.bar-fill.green { background: #2ea043; } +.bar-fill.red { background: #f85149; } +.bar-fill.blue { background: #58a6ff; } +.bar b { + text-align: right; + font-variant-numeric: tabular-nums; + color: #e6edf3; + font-size: 12px; +} + +/* Steering wheel visual */ +.wheel { + display: flex; + justify-content: center; + margin-top: 10px; +} +.wheel-disc { + width: 90px; + height: 90px; + border-radius: 50%; + background: radial-gradient(circle at center, #30363d 0%, #21262d 70%); + border: 2px solid #484f58; + position: relative; + transition: transform 80ms linear; +} +.wheel-disc::after { + content: ''; + position: absolute; + top: 4px; + left: 50%; + width: 4px; + height: 14px; + background: #f0883e; + transform: translateX(-50%); + border-radius: 2px; +} + +/* Telemetry */ +.tel { + display: grid; + grid-template-columns: 100px 1fr; + gap: 4px 12px; + margin: 0; + font-size: 12px; +} +.tel dt { color: #8b949e; } +.tel dd { + margin: 0; + font-variant-numeric: tabular-nums; + color: #e6edf3; +} + +/* Log */ +.log-card .log { + margin: 0; + padding: 8px; + background: #0a0d12; + border: 1px solid #30363d; + border-radius: 4px; + height: 160px; + overflow-y: auto; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; + font-size: 11px; + line-height: 1.45; + color: #c9d1d9; + white-space: pre-wrap; + word-break: break-word; +} +.log .l-info { color: #58a6ff; } +.log .l-warn { color: #d29922; } +.log .l-error { color: #f85149; } +.log .l-rx { color: #2ea043; } +.log .l-tx { color: #f0883e; } +.log .l-meta { color: #8b949e; } + +@media (max-width: 900px) { + .layout { grid-template-columns: 1fr; } +} \ No newline at end of file