mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
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.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.idea/
|
||||
.minimax/
|
||||
*.csv
|
||||
server/.out
|
||||
server/.err
|
||||
server/poc-server.exe
|
||||
**/__pycache__/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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
|
||||
+223
@@ -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).
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
+502
@@ -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-аккаунт** — есть ли уже ИП/ООО для приёма платежей, или
|
||||
регистрировать при переходе на биллинг?
|
||||
@@ -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/<you>/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).
|
||||
@@ -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=<string>`.
|
||||
// @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=<string>`.
|
||||
// @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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "<message_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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
+2734
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
+100
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package races
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Cursor is a keyset cursor: opaque to the client, encoded as
|
||||
// base64url("<ms>:<id>") 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 <ms>:<id>", 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
version: v2
|
||||
modules:
|
||||
- path: .
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
breaking:
|
||||
use:
|
||||
- FILE
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <seeds.json>")
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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).
|
||||
@@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>x0gp · PoC client</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="logo">x0gp</span>
|
||||
<span class="tag">PoC client</span>
|
||||
</div>
|
||||
<div class="conn" id="connState">disconnected</div>
|
||||
<div class="hud-top" id="hudTop">
|
||||
<span>tick: <b id="tickHz">--</b> Hz</span>
|
||||
<span>rtt: <b id="rtt">--</b> ms</span>
|
||||
<span>seq: <b id="seq">0</b></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<!-- Левая колонка: трасса -->
|
||||
<section class="track-wrap">
|
||||
<canvas id="track" width="640" height="420"></canvas>
|
||||
<div class="legend">
|
||||
<span><i class="dot me"></i> вы</span>
|
||||
<span><i class="dot other"></i> другие</span>
|
||||
<span><i class="dot ghost"></i> correction (если будет)</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Правая колонка: управление и телеметрия -->
|
||||
<aside class="controls">
|
||||
<div class="card">
|
||||
<h3>Подключение</h3>
|
||||
<label>WS URL
|
||||
<input id="wsUrl" type="text" value="ws://localhost:8080/ws" />
|
||||
</label>
|
||||
<label>Ник / driver_id
|
||||
<input id="driverId" type="text" placeholder="driver-1" value="driver-1" />
|
||||
</label>
|
||||
<label>Race ID
|
||||
<input id="raceId" type="text" value="demo-race" />
|
||||
</label>
|
||||
<div class="btn-row">
|
||||
<button id="btnConnect" class="btn primary">Connect</button>
|
||||
<button id="btnDisconnect" class="btn" disabled>Disconnect</button>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button id="btnJoin" class="btn" disabled>Join race</button>
|
||||
<button id="btnLeave" class="btn" disabled>Leave race</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Управление</h3>
|
||||
<p class="hint">
|
||||
W/S — газ/тормоз, A/D — руль, Space — ручник, R — сброс позиции (debug)
|
||||
</p>
|
||||
<div class="pedals">
|
||||
<div class="bar">
|
||||
<span>throttle</span>
|
||||
<div class="bar-track"><div id="barThrottle" class="bar-fill green"></div></div>
|
||||
<b id="lblThrottle">0.00</b>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<span>brake</span>
|
||||
<div class="bar-track"><div id="barBrake" class="bar-fill red"></div></div>
|
||||
<b id="lblBrake">0.00</b>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<span>steer</span>
|
||||
<div class="bar-track"><div id="barSteer" class="bar-fill blue"></div></div>
|
||||
<b id="lblSteer">0.00</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wheel">
|
||||
<div class="wheel-disc" id="wheelDisc"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Телеметрия</h3>
|
||||
<dl class="tel">
|
||||
<dt>speed</dt><dd id="tSpeed">0.00 m/s</dd>
|
||||
<dt>heading</dt><dd id="tHeading">0.0°</dd>
|
||||
<dt>lap</dt><dd id="tLap">0</dd>
|
||||
<dt>sector</dt><dd id="tSector">-</dd>
|
||||
<dt>pos</dt><dd id="tPos">(0.00, 0.00)</dd>
|
||||
<dt>server_time</dt><dd id="tTsm">--</dd>
|
||||
<dt>cars in race</dt><dd id="tCars">0</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card log-card">
|
||||
<h3>Лог</h3>
|
||||
<pre id="log" class="log"></pre>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user