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:
x0gp
2026-06-22 22:01:09 +04:00
commit 978d36c505
71 changed files with 23500 additions and 0 deletions
+27
View File
@@ -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
+40
View File
@@ -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/
+34
View File
@@ -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"]
+87
View File
@@ -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
View File
@@ -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-аккаунт** — есть ли уже ИП/ООО для приёма платежей, или
регистрировать при переходе на биллинг?
+209
View File
@@ -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).
+736
View File
@@ -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)
}
}
}
+299
View File
@@ -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)
}
}
}
+709
View File
@@ -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
}
+506
View File
@@ -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,
}
}
+16
View File
@@ -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"]
+90
View File
@@ -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
View File
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
+31
View File
@@ -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
View File
@@ -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=
+609
View File
@@ -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
}
+401
View File
@@ -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))
}
}
+84
View File
@@ -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
}
+30
View File
@@ -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
}
+75
View File
@@ -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
}
+546
View File
@@ -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.
}
}
}
+104
View File
@@ -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)
}
+171
View File
@@ -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
}
+117
View File
@@ -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
}
+296
View File
@@ -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
}
+110
View File
@@ -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)
}
+188
View File
@@ -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
}
+501
View File
@@ -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 }
+120
View File
@@ -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")
}
}
+293
View File
@@ -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)
}
}()
}
+57
View File
@@ -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
}
+75
View File
@@ -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)
}
+562
View File
@@ -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
}
+714
View File
@@ -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() }
+803
View File
@@ -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,
}
}
+118
View File
@@ -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
}
+168
View File
@@ -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,
}
}
+109
View File
@@ -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)
}
}
+363
View File
@@ -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;
+373
View File
@@ -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
}
+836
View File
@@ -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
}
+10
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
version: v2
modules:
- path: .
lint:
use:
- STANDARD
breaking:
use:
- FILE
+145
View 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;
}
+96
View File
@@ -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;
}
+184
View File
@@ -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)
}
+200
View File
@@ -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)
}
+96
View File
@@ -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)
}
+83
View File
@@ -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).
+106
View File
@@ -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>
+468
View File
@@ -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');
})();
+267
View File
@@ -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; }
}