mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-18 21:37:04 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d2e0d59df | ||
|
|
a5d2ee32c6 | ||
|
|
9c9632343d | ||
|
|
c8ff4c04ad | ||
|
|
a5fd186320 | ||
|
|
1069c93e6d | ||
|
|
68027f0524 | ||
|
|
f6547cd1f1 | ||
|
|
41eaf49bb2 | ||
|
|
53288ea670 | ||
|
|
a56841237d | ||
|
|
4a894a4399 | ||
|
|
c25e0bc581 |
@@ -11,3 +11,5 @@ server/poc-server.exe
|
||||
.grepai/
|
||||
*.blend1
|
||||
*.blend2
|
||||
node_modules/
|
||||
|
||||
|
||||
@@ -43,6 +43,17 @@
|
||||
|
||||
- Go 1.26, бинарь `cmd/poc-server`. Сборка: `go build ./...` или `go build -o poc-server.exe ./cmd/poc-server`.
|
||||
- HTTP+WebSocket, роутер `http.NewServeMux`. Запуск требует `DATABASE_URL` (см. `.env`).
|
||||
- **База данных (Supabase)**:
|
||||
- Используется self-hosted Supabase на порту `3402` (хост: `80.234.93.161:3402`).
|
||||
- Расширение `timescaledb` отключено (закомментировано в `001_init.sql`), так как Supabase не поставляет его из коробки.
|
||||
- Нативная папка `supabase/` содержит `config.toml`, объединенный дамп схемы `supabase/migrations/20260717000000_schema.sql` и каталог семян `supabase/seed.sql` (включает рескейл Монако).
|
||||
- Объединенный дамп схемы автоматически заполняет таблицу `schema_migrations`, чтобы предотвратить ошибки повторного применения миграций Go-сервером на старте.
|
||||
- **Абстракция данных (Store)**:
|
||||
- Пакеты `clans` и `drivers` используют интерфейсы `Store` вместо прямого импорта `*PgStore`.
|
||||
- Это позволяет писать юнит-тесты сервисов без подключения к реальной БД (через `memoryStore`).
|
||||
- **Слепок структуры (Graphify)**:
|
||||
- Утилита `scripts/graphify/main.go` собирает описание всех экспортируемых пакетов, структур, интерфейсов и функций в компактный файл [server/CODEBASE.md](file:///d:/x0gp/server/CODEBASE.md).
|
||||
- Рекомендуется запускать `go run ./scripts/graphify/main.go` после значимых изменений API для поддержания карты проекта в актуальном состоянии и экономии токенов при чтении агентом.
|
||||
- **Swagger UI**: `GET /swagger/index.html`, спека `GET /swagger/doc.json` (14 paths, 29 definitions).
|
||||
- Генерация спеки: `swag init -g cmd/poc-server/main.go -o docs --parseInternal` из `D:/x0gp/server`. CLI ставится через `go install github.com/swaggo/swag/cmd/swag@v1.16.4`.
|
||||
- Аннотации swag живут над хендлерами в `cmd/poc-server/main.go`, `catalog_handlers.go`, `races_handlers.go`. При добавлении нового REST-эндпоинта — добавить `@Router`/`@Param`/`@Success` и перегенерить `docs/`.
|
||||
@@ -56,6 +67,8 @@
|
||||
|
||||
### API гонок (keyset-пагинация)
|
||||
- `GET /api/races` — список (live + finished), фильтры `status,track_id`, `cursor`, `limit` (1..200, default 50). Status enum: `all|lobby|racing|finished`. **Кастомных лобби нет** — гонки привязаны к физическим трекам, фильтр только по `track_id`. Cursor — base64(`ms:id[:src]`).
|
||||
- `GET /api/races/last` — результаты (podium/details) последнего законченного заезда.
|
||||
- `GET /api/races/{id}` — результаты (podium/details) или статус конкретного заезда по ID (работает для активных и завершенных гонок).
|
||||
- `GET /api/races/upcoming?limit=3` — ближайшие lobby|countdown.
|
||||
- `POST /api/races/queue/join` — встать в очередь на следующие N (body: `driver_id,limit` или header `X-Driver-Id`).
|
||||
- `GET /api/races/queue?driver_id=...` — посмотреть.
|
||||
@@ -69,7 +82,8 @@
|
||||
- CRUD:
|
||||
- `POST/GET /api/clans`, `GET/PUT/DELETE /api/clans/{id}`, `GET /api/clans/by-tag/{tag}`
|
||||
- `POST/GET /api/drivers`, `GET/PUT/DELETE /api/drivers/{id}`, `GET /api/drivers/by-nick/{nick}`
|
||||
- `PUT /api/drivers/{id}` принимает `clan_id` как `*string` (nil = оставить, "" = очистить, иначе = назначить).
|
||||
- `GET /api/drivers/{id}/profile` — профиль с агрегированной статистикой (всего стартов/финишей, победы, подиумы, лучшее время круга, суммарное время на треке) и сводками лучшей и последней завершенной гонки. Можно вызвать как `/api/drivers/me/profile` для получения профиля авторизованного гонщика.
|
||||
- `PUT /api/drivers/{id}` принимает `nickname` (3 уникальные заглавные латинские буквы), `name`, `avatar_url` и `clan_id` как `*string` (nil = оставить, "" = очистить, иначе = назначить). Редактирование и удаление чужого профиля запрещено при `DevMode = false`.
|
||||
- Сидер (через `--seed-races`) заполняет 3 клана и 8 водителей, треки берутся из `tracks` каталога (никаких выдуманных id в `finished_races`/`race_plans`).
|
||||
- Podium в `LobbyRace` заполняется только для finished-гонок (top-3: position, driver_id, name, total_time_ms).
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
#include "esp_camera.h"
|
||||
#include <WiFi.h>
|
||||
#include <WiFiUdp.h>
|
||||
#include <ESP32Servo.h>
|
||||
|
||||
// === НАСТРОЙКА РЕЖИМА ЗАГЛУШКИ ===
|
||||
const bool USE_MOCK_IF_NO_CAMERA = true; // true - слать черный экран, false - ничего не слать
|
||||
|
||||
Servo myServo;
|
||||
const int servoPin = 13; // ПРИОРИТЕТ: Пин жестко закреплен за сервоприводом
|
||||
|
||||
// !!! УНИКАЛЬНЫЙ ID ДЛЯ КАЖДОЙ ПЛАТЫ
|
||||
const uint8_t DEVICE_ID = 1;
|
||||
|
||||
// Конфигурация пинов (пример для Seeed Studio XIAO ESP32S3 Sense)
|
||||
#define PWDN_GPIO_NUM -1
|
||||
@@ -10,7 +20,7 @@
|
||||
#define SIOC_GPIO_NUM 39
|
||||
#define Y9_GPIO_NUM 48
|
||||
#define Y8_GPIO_NUM 47
|
||||
#define Y7_GPIO_NUM 13
|
||||
#define Y7_GPIO_NUM 18 // ИЗМЕНЕНО: Отключаем этот пин для камеры, отдаем под Серво
|
||||
#define Y6_GPIO_NUM 14
|
||||
#define Y5_GPIO_NUM 12
|
||||
#define Y4_GPIO_NUM 11
|
||||
@@ -20,24 +30,45 @@
|
||||
#define HREF_GPIO_NUM 38
|
||||
#define PCLK_GPIO_NUM 37
|
||||
|
||||
|
||||
const char* ssid = "RT-WiFi-1712";
|
||||
const char* password = "yt3xihY2Ci";
|
||||
|
||||
const char* serverIP = "192.168.0.11";
|
||||
const char* serverIP = "192.168.0.10";
|
||||
const int serverPort = 9999;
|
||||
const int localPort = 8888;
|
||||
|
||||
// !!! УНИКАЛЬНЫЙ ID ДЛЯ КАЖДОЙ ПЛАТЫ (например, 1 для первой, 2 для второй и т.д.)
|
||||
const uint8_t DEVICE_ID = 1;
|
||||
|
||||
|
||||
WiFiUDP udpVideo;
|
||||
WiFiUDP udpCmd;
|
||||
camera_config_t config;
|
||||
|
||||
// Флаг успешной инициализации камеры
|
||||
bool isCameraInitialized = false;
|
||||
|
||||
// Моковый минимальный валидный JPEG черного цвета
|
||||
const uint8_t mockBlackJpeg[] = {
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x01, 0x00, 0x60,
|
||||
0x00, 0x60, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x08,
|
||||
0x00, 0x08, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0xFF, 0xC4, 0x00, 0x14,
|
||||
0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0xFF, 0xD9
|
||||
};
|
||||
const size_t mockSize = sizeof(mockBlackJpeg);
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
// Настройка камеры (QVGA для стабильности)
|
||||
|
||||
// Инициализируем сервопривод в первую очередь
|
||||
myServo.attach(servoPin, 500, 2500);
|
||||
Serial.println("Сервопривод инициализирован на пине 13");
|
||||
|
||||
// Настройка конфигурации камеры
|
||||
config.ledc_channel = LEDC_CHANNEL_0;
|
||||
config.ledc_timer = LEDC_TIMER_0;
|
||||
config.pin_d0 = Y2_GPIO_NUM;
|
||||
@@ -45,7 +76,7 @@ void setup() {
|
||||
config.pin_d2 = Y4_GPIO_NUM;
|
||||
config.pin_d3 = Y5_GPIO_NUM;
|
||||
config.pin_d4 = Y6_GPIO_NUM;
|
||||
config.pin_d5 = Y7_GPIO_NUM;
|
||||
config.pin_d5 = Y7_GPIO_NUM; // Передаст -1, библиотека не тронет 13 пин
|
||||
config.pin_d6 = Y8_GPIO_NUM;
|
||||
config.pin_d7 = Y9_GPIO_NUM;
|
||||
config.pin_xclk = XCLK_GPIO_NUM;
|
||||
@@ -62,7 +93,15 @@ void setup() {
|
||||
config.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
|
||||
config.fb_count = 2;
|
||||
|
||||
esp_camera_init(&config);
|
||||
// Проверяем статус инициализации камеры
|
||||
esp_err_t err = esp_camera_init(&config);
|
||||
if (err == ESP_OK) {
|
||||
isCameraInitialized = true;
|
||||
Serial.println("Камера успешно инициализирована!");
|
||||
} else {
|
||||
isCameraInitialized = false;
|
||||
Serial.printf("Камера не найдена (код 0x%x). Режим мок-данных: %s\n", err, USE_MOCK_IF_NO_CAMERA ? "ВКЛ" : "ВЫКЛ");
|
||||
}
|
||||
|
||||
// Подключение к Wi-Fi
|
||||
WiFi.begin(ssid, password);
|
||||
@@ -79,43 +118,58 @@ void setup() {
|
||||
|
||||
void checkIncomingCommands() {
|
||||
int packetSize = udpCmd.parsePacket();
|
||||
if (packetSize > 0) {
|
||||
char packetBuffer[255];
|
||||
int len = udpCmd.read(packetBuffer, 255);
|
||||
if (len > 0) { packetBuffer[len] = 0; }
|
||||
|
||||
String command = String(packetBuffer);
|
||||
command.trim();
|
||||
if (packetSize == 5) {
|
||||
uint8_t packetBuffer[5];
|
||||
int len = udpCmd.read(packetBuffer, 5);
|
||||
if (len == 5) {
|
||||
uint8_t steer = packetBuffer[0];
|
||||
uint8_t throttle = packetBuffer[1];
|
||||
uint8_t brake = packetBuffer[2];
|
||||
uint8_t gear = packetBuffer[3];
|
||||
uint8_t flags = packetBuffer[4];
|
||||
|
||||
Serial.print("Получена команда: ");
|
||||
Serial.println(command);
|
||||
// ИСПРАВЛЕНО: Инвертирован диапазон (180, 0 вместо 0, 180) для исправления направления
|
||||
int servoAngle = map(steer, 0, 200, 180, 0);
|
||||
myServo.write(servoAngle);
|
||||
|
||||
Serial.print("Получена команда: ");
|
||||
Serial.println(command);
|
||||
|
||||
// Логика управления
|
||||
if (command == "left") {
|
||||
// Ваш код поворота налево
|
||||
} else if (command == "right") {
|
||||
// Ваш код поворота направо
|
||||
} else if (command == "start") {
|
||||
// Ваш код запуска моторов / логики
|
||||
} else if (command == "stop") {
|
||||
// Ваш код остановки
|
||||
Serial.printf("Got control packet: Steer=%d (Angle=%d), Throttle=%d%%, Brake=%d%%, Gear=%d, Flags=0x%02X\n",
|
||||
steer, servoAngle, throttle, brake, gear, flags);
|
||||
}
|
||||
} else if (packetSize > 0) {
|
||||
char garbage[255];
|
||||
udpCmd.read(garbage, sizeof(garbage));
|
||||
}
|
||||
}
|
||||
|
||||
void loop() {
|
||||
checkIncomingCommands();
|
||||
|
||||
camera_fb_t * fb = esp_camera_fb_get();
|
||||
if (!fb) return;
|
||||
uint8_t * buf = nullptr;
|
||||
size_t bufferSize = 0;
|
||||
camera_fb_t * fb = nullptr;
|
||||
|
||||
size_t bufferSize = fb->len;
|
||||
uint8_t * buf = fb->buf;
|
||||
// Пытаемся получить кадр, если камера запущена
|
||||
if (isCameraInitialized) {
|
||||
fb = esp_camera_fb_get();
|
||||
if (fb) {
|
||||
bufferSize = fb->len;
|
||||
buf = fb->buf;
|
||||
}
|
||||
}
|
||||
|
||||
// Если кадра с камеры нет
|
||||
if (!buf) {
|
||||
// Если отправка заглушки отключена — выходим из цикла, продолжая слушать команды
|
||||
if (!USE_MOCK_IF_NO_CAMERA) {
|
||||
delay(10);
|
||||
return;
|
||||
}
|
||||
// Если включена — подставляем моковый черный кадр
|
||||
bufferSize = mockSize;
|
||||
buf = (uint8_t *)mockBlackJpeg;
|
||||
}
|
||||
|
||||
// Размер куска данных уменьшаем до 1023, чтобы вместе с 1 байтом ID пакет был 1024 байта
|
||||
// Нарезка и отправка по UDP (оригинальная логика)
|
||||
size_t chunkSize = 1023;
|
||||
uint8_t packetBuffer[1024];
|
||||
|
||||
@@ -125,15 +179,18 @@ void loop() {
|
||||
sendSize = bufferSize - i;
|
||||
}
|
||||
|
||||
// Формируем пакет: первый байт — ID, дальше — видеоданные
|
||||
packetBuffer[0] = DEVICE_ID;
|
||||
memcpy(packetBuffer + 1, buf + i, sendSize);
|
||||
|
||||
udpVideo.beginPacket(serverIP, serverPort);
|
||||
udpVideo.write(packetBuffer, sendSize + 1); // Отправляем данные вместе с ID
|
||||
udpVideo.write(packetBuffer, sendSize + 1);
|
||||
udpVideo.endPacket();
|
||||
}
|
||||
|
||||
esp_camera_fb_return(fb);
|
||||
// Возвращаем буфер только если он реальный
|
||||
if (fb) {
|
||||
esp_camera_fb_return(fb);
|
||||
}
|
||||
|
||||
delay(10);
|
||||
}
|
||||
|
||||
Generated
+233
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"name": "x0gp",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"supabase": "^2.109.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ecies/ciphers": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz",
|
||||
"integrity": "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1",
|
||||
"deno": ">=2.7.10",
|
||||
"node": ">=16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/ciphers": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
|
||||
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/cli-darwin-arm64": {
|
||||
"version": "2.109.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.109.1.tgz",
|
||||
"integrity": "sha512-tkn8tfunyqIL7RE+7DVjg6Ql2cJLPkGgh9cPafp2LbXI0qDgds0TaS+UOTHQEjci8JQXXe2wS00+122ko2QI8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@supabase/cli-darwin-x64": {
|
||||
"version": "2.109.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.109.1.tgz",
|
||||
"integrity": "sha512-0Q5ZAoWhOyIv4ZzQOU8QbjjdB2JmznnDNyJ3VrIeuLMWoifVfUWaLZhHBNYzr4xXoxBpKrggomuAT8BaEhY3lg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@supabase/cli-linux-arm64": {
|
||||
"version": "2.109.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.109.1.tgz",
|
||||
"integrity": "sha512-MS1djJjq5laD99+jYJUoARfSZhzRX9aXmo5piZ1yl2JXb9IXTuhiTD5olkFKQcgNckRcAZqMf4fucQGTYC767A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@supabase/cli-linux-arm64-musl": {
|
||||
"version": "2.109.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.109.1.tgz",
|
||||
"integrity": "sha512-cXNOsSU7MS+jmslGQATTD4DfWBEIHiFi7LaBWyBxHmR7tzIiZXimUXgN26ZiQjfAxU+RZq52C3k0qhi7vmqXQw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@supabase/cli-linux-x64": {
|
||||
"version": "2.109.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.109.1.tgz",
|
||||
"integrity": "sha512-svFmamF/vIq4/oinwY50jDi869itC9/GWrPaGtsHFkK4NUBcQtl1T37WWIivGsXwbBKNC4FjZD3dGqjL7bfW1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@supabase/cli-linux-x64-musl": {
|
||||
"version": "2.109.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.109.1.tgz",
|
||||
"integrity": "sha512-zeD9MrZpEKJrjkSPcYp4nZeGJ9FQt0i/kiRij4ZprPP6R5l+SLi6Pk20ln+Vj/GZuWTVxX7IHJ11pgViaReAWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@supabase/cli-windows-arm64": {
|
||||
"version": "2.109.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.109.1.tgz",
|
||||
"integrity": "sha512-NeKzgWAOpglnLwMTggTp5t44fbkfxACLkQNlCRx0q332HMM3WqsKQUsHv1MHc6ZbEc5bcMwaYjqPNI/aOWnP5g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@supabase/cli-windows-x64": {
|
||||
"version": "2.109.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.109.1.tgz",
|
||||
"integrity": "sha512-L9/pDLM+4IR8646aT69ZDVcnJeg1lZZsB26ZgI775S3f8jOHP41+1UH9Oq1g9CvKiAQviuaLgtwkuvi0I/cc/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/eciesjs": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.5.0.tgz",
|
||||
"integrity": "sha512-s0J9SEVYAEPg7J63GFMApLYzPH9VNIQIyC6s15JpnqVc0TqcKWdbgFlnAweEBRyMmko2dcs2sfC83Hj4J43tuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ecies/ciphers": "^0.2.6",
|
||||
"@noble/ciphers": "^1.3.0",
|
||||
"@noble/curves": "^1.9.7",
|
||||
"@noble/hashes": "^1.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1",
|
||||
"deno": ">=2.7.10",
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/supabase": {
|
||||
"version": "2.109.1",
|
||||
"resolved": "https://registry.npmjs.org/supabase/-/supabase-2.109.1.tgz",
|
||||
"integrity": "sha512-N2yP2MHTxOxXBWhfn3poudpJn4pkPosAUo7J/46FTou/l7wOwFi9tox8NSN6HljWkfM0zhwPRimNNGC9XBMoxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eciesjs": "^0.5.0",
|
||||
"jose": "^6.2.3"
|
||||
},
|
||||
"bin": {
|
||||
"supabase": "dist/supabase.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@supabase/cli-darwin-arm64": "2.109.1",
|
||||
"@supabase/cli-darwin-x64": "2.109.1",
|
||||
"@supabase/cli-linux-arm64": "2.109.1",
|
||||
"@supabase/cli-linux-arm64-musl": "2.109.1",
|
||||
"@supabase/cli-linux-x64": "2.109.1",
|
||||
"@supabase/cli-linux-x64-musl": "2.109.1",
|
||||
"@supabase/cli-windows-arm64": "2.109.1",
|
||||
"@supabase/cli-windows-x64": "2.109.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"supabase": "^2.109.1"
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -9,7 +9,7 @@
|
||||
# postgres://x0gp:x0gp@localhost:5432/x0gp?sslmode=disable
|
||||
# postgres://x0gp:x0gp@postgres:5432/x0gp?sslmode=disable (inside compose)
|
||||
|
||||
DATABASE_URL=postgres://x0gp:x0gp@localhost:5432/x0gp?sslmode=disable
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:3402/postgres?sslmode=disable
|
||||
|
||||
# HTTP server.
|
||||
X0GP_HTTP_ADDR=:8080
|
||||
@@ -19,9 +19,13 @@ X0GP_LOG_LEVEL=info
|
||||
X0GP_TICK_RATE=60
|
||||
X0GP_SNAPSHOT_RATE=30
|
||||
X0GP_MAX_CLIENTS=100
|
||||
X0GP_SEED_RACES=0
|
||||
X0GP_SEED_RESET=0
|
||||
|
||||
# Optional: override path(s) to dotenv files (colon-separated).
|
||||
# X0GP_DOTENV=.env:./configs/local.env
|
||||
# PoC: skip auth, allow multiple clients on the same track.
|
||||
X0GP_DEV_MODE=true
|
||||
|
||||
# Optional: override path(s) to dotenv files (colon-separated).
|
||||
# X0GP_DOTENV=.env:./configs/local.env
|
||||
# JWT secret (Supabase service role or JWT secret). Required when X0GP_DEV_MODE=false.
|
||||
X0GP_JWT_SECRET=your-supabase-jwt-secret-here
|
||||
@@ -0,0 +1,764 @@
|
||||
# x0gp Go Codebase Map (Generated)
|
||||
|
||||
This file contains a compiled map of all packages, structures, interfaces, and public functions in the `server` directory. It is optimized for token-efficiency so AI agents and humans can understand the codebase layout without reading all files.
|
||||
|
||||
## Package `main` (`cmd/poc-server`)
|
||||
|
||||
HTTP handlers for the catalog (tracks + cars).
|
||||
|
||||
All HTTP routes live under /api/*:
|
||||
GET /api/tracks list tracks (optional ?scope=system|public|private&tag=)
|
||||
GET /api/tracks/{id} get a single track
|
||||
POST /api/tracks create a track
|
||||
PUT /api/tracks/{id} update a track
|
||||
DELETE /api/tracks/{id} delete a track
|
||||
GET /api/cars list cars (optional ?scope=...&owner_id=...)
|
||||
GET /api/cars/{id} get a single car
|
||||
POST /api/cars create a car
|
||||
PUT /api/cars/{id} update a car
|
||||
DELETE /api/cars/{id} delete a car
|
||||
GET /api/catalog combined snapshot + summary
|
||||
|
||||
WebSocket access is wired up directly in main.go's readPump switch.
|
||||
|
||||
### Structs
|
||||
- **struct Hub**
|
||||
- **struct UDPVideoService**
|
||||
- **struct WebRTCService**
|
||||
|
||||
### Functions
|
||||
- func NewHub(*slog.Logger) *Hub
|
||||
- func NewUDPVideoService(*slog.Logger, string, int) *UDPVideoService
|
||||
- func NewWebRTCService(*slog.Logger, *control.Engine, *lobby.Service, *UDPVideoService, *catalog.Service) *WebRTCService
|
||||
|
||||
### Methods
|
||||
- func (*catalogBroadcaster) Run()
|
||||
- func (*catalogBroadcaster) Stop()
|
||||
- func (*Hub) BroadcastSnapshot(transport.RaceSnapshot)
|
||||
- func (*statusRecorder) WriteHeader(int)
|
||||
- func (*statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error)
|
||||
- func (*UDPVideoService) Start(context.Context, *sync.WaitGroup)
|
||||
- func (*UDPVideoService) SendControlPacket(uint8, []byte) error
|
||||
- func (*UDPVideoService) SendCommand(uint8, string) error
|
||||
- func (*UDPVideoService) StreamHandler() http.HandlerFunc
|
||||
- func (*UDPVideoService) ControlHandler() http.HandlerFunc
|
||||
- func (*WebRTCService) Start(context.Context, *sync.WaitGroup)
|
||||
- func (*WebRTCService) ConnectHandler() http.HandlerFunc
|
||||
- func (*WebRTCService) CloseSession(string)
|
||||
- func (*WebRTCService) CloseAll()
|
||||
|
||||
---
|
||||
|
||||
## Package `clans` (`internal/clans`)
|
||||
|
||||
Package clans owns persistence and business logic for clans.
|
||||
|
||||
A clan has a unique 3-letter tag (A-Z, uppercase, exactly 3 chars),
|
||||
a name, and an optional avatar URL. Drivers reference a clan via a
|
||||
nullable FK (see internal/drivers).
|
||||
|
||||
### Interfaces
|
||||
- **interface Store** { Exec, Create, Get, GetByTag, List, Delete, Update, Count }
|
||||
|
||||
### Structs
|
||||
- **struct Service**
|
||||
- **struct CreateInput** { ID, Tag, Name, AvatarURL }
|
||||
- **struct UpdateInput** { Name, AvatarURL }
|
||||
- **struct Clan** { ID, Tag, Name, AvatarURL, CreatedMs, UpdatedMs }
|
||||
- **struct PgStore**
|
||||
|
||||
### Functions
|
||||
- func NewService(Store) *Service
|
||||
- func IsValidTag(string) bool
|
||||
- func NewPgStore(*pgxpool.Pool) *PgStore
|
||||
|
||||
### Methods
|
||||
- func (*Service) Create(context.Context, CreateInput) (Clan, error)
|
||||
- func (*Service) Get(context.Context, string) (Clan, error)
|
||||
- func (*Service) GetByTag(context.Context, string) (Clan, error)
|
||||
- func (*Service) List(context.Context, int, int) ([]Clan, error)
|
||||
- func (*Service) Update(context.Context, string, UpdateInput) (Clan, error)
|
||||
- func (*Service) Delete(context.Context, string) error
|
||||
- func (*PgStore) Exec(context.Context, string) (int64, error)
|
||||
- func (*PgStore) Create(context.Context, Clan) error
|
||||
- func (*PgStore) Get(context.Context, string) (Clan, error)
|
||||
- func (*PgStore) GetByTag(context.Context, string) (Clan, error)
|
||||
- func (*PgStore) List(context.Context, int, int) ([]Clan, error)
|
||||
- func (*PgStore) Delete(context.Context, string) error
|
||||
- func (*PgStore) Update(context.Context, Clan) error
|
||||
- func (*PgStore) Count(context.Context) (int, error)
|
||||
|
||||
---
|
||||
|
||||
## Package `lobby` (`internal/lobby`)
|
||||
|
||||
Package lobby owns the "outside any race" state of the server: which
|
||||
races are open, which drivers are connected but idle, who is the host
|
||||
of each race.
|
||||
|
||||
The lobby is the single source of truth for matchmaking and for
|
||||
rendering the "what races are available right now" list. It does NOT
|
||||
own per-race physics — that's the Engine (internal/control).
|
||||
|
||||
All operations are thread-safe. Mutations broadcast a snapshot event
|
||||
to subscribers (see Service.Subscribe).
|
||||
|
||||
### Interfaces
|
||||
- **interface Persistence** { UpsertRace, DeleteRace, AddDriver, RemoveDriver, SetRaceStatus, UpsertDriver, DeleteDriver }
|
||||
|
||||
### Structs
|
||||
- **struct CreateRaceOptions** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS }
|
||||
- **struct RaceMeta** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, DriverIDs, Status, CreatedMs, StartedMs }
|
||||
- **struct DriverMeta** { ID, Name, Nickname, AvatarURL, ClanID, ClanTag, Status, RaceID, ConnectedMs, LastSeenMs, Country, AvatarHue, CarID }
|
||||
- **struct Snapshot** { GeneratedMs, Races, Drivers, Version }
|
||||
- **struct Stats** { RacesTotal, RacesOpen, RacesActive, DriversIdle, DriversTotal }
|
||||
- **struct Service**
|
||||
|
||||
### Functions
|
||||
- func NewService() *Service
|
||||
|
||||
### Methods
|
||||
- func (*Service) CreateRace(CreateRaceOptions) (RaceMeta, error)
|
||||
- func (*Service) AddRace(RaceMeta)
|
||||
- func (*Service) AddDriver(string, string, string) (DriverMeta, error)
|
||||
- func (*Service) RemoveDriver(string)
|
||||
- func (*Service) Heartbeat(string)
|
||||
- func (*Service) SetDriverProfile(string, string, string, string, string)
|
||||
- func (*Service) AddDriverToRace(string, string) error
|
||||
- func (*Service) RemoveDriverFromRace(string)
|
||||
- func (*Service) SetRaceStatus(string, RaceStatus) error
|
||||
- func (*Service) DeleteRace(string) error
|
||||
- func (*Service) QuickPlay(string, int) (RaceMeta, error)
|
||||
- func (*Service) GetRace(string) (RaceMeta, error)
|
||||
- func (*Service) GetDriver(string) (DriverMeta, error)
|
||||
- func (*Service) SelectCar(string, *string) error
|
||||
- func (*Service) ListRaces() []RaceMeta
|
||||
- func (*Service) ListDrivers() []DriverMeta
|
||||
- func (*Service) SetPersistence(Persistence)
|
||||
- func (*Service) SetRaceLifecycleHook(func, func)
|
||||
- func (*Service) Subscribe() (?, func)
|
||||
- func (*Service) Stop()
|
||||
- func (*Service) Done() ?
|
||||
- func (*Service) Snapshot() Snapshot
|
||||
- func (*Service) Stats() Stats
|
||||
|
||||
---
|
||||
|
||||
## Package `races` (`internal/races`)
|
||||
|
||||
Package races owns the cross-cutting "race list" surface: keyset-paginated
|
||||
listing of live and finished races, upcoming (next-N) selection, the
|
||||
driver-to-race queue and the recurring race-plan scheduler.
|
||||
|
||||
The package composes two storage backends:
|
||||
|
||||
- lobby.Service (in-memory): authoritative source for active races
|
||||
(status = lobby | countdown | racing). Used directly for the live
|
||||
page of /api/races, for the upcoming view and for the scheduler
|
||||
materialisation.
|
||||
- Postgres (pgxpool): authoritative for finished races (snapshotted
|
||||
on SetRaceStatus(finished)), for race plans and for the queue.
|
||||
Finished races are read with keyset pagination on
|
||||
(finished_ms DESC, id DESC).
|
||||
|
||||
### Structs
|
||||
- **struct Cursor** { Ms, ID }
|
||||
- **struct LeaderboardEntry** { DriverID, Name, Nickname, AvatarURL, ClanID, ClanTag, Points, BestPos, BestTimeMs, Rank }
|
||||
- **struct LeaderboardResult** { Items, Total, CurrentDriver }
|
||||
- **struct TrackCalendarInfo** { ID, TrackID, TrackName, StartAt, EndAt }
|
||||
- **struct TrackCalendarLeaderboard** { TrackID, TrackName, StartAt, EndAt, Status, Podium, CurrentDriver }
|
||||
- **struct LiveStore**
|
||||
- **struct Service**
|
||||
- **struct ListFilter** { Statuses, TrackID, Cursor, Limit }
|
||||
- **struct RaceItem** { Source, Meta, Podium }
|
||||
- **struct ListResult** { Items, NextCursor, TotalCount }
|
||||
- **struct UpcomingItem** { Meta, QueueLen, PlanID, StartAtMs }
|
||||
- **struct JoinUpcomingResult** { Joined, Skipped }
|
||||
- **struct SnapshotFinished** { Meta, FinishedMs, TotalLaps, WinnerDriverID, WinnerName, BestLapMs, Results }
|
||||
- **struct CreatePlanInput** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, StartAtMs, IntervalS, Count, Enabled }
|
||||
- **struct Scheduler**
|
||||
- **struct PodiumEntry** { Position, DriverID, Name, TotalTimeMs, Nickname, AvatarUrl, ClanID }
|
||||
- **struct DriverResult** { DriverID, Name, TotalTimeMs, BestLapMs, Position, Nickname, AvatarUrl, ClanID }
|
||||
- **struct FinishedRace** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, DriverIDs, Status, CreatedMs, StartedMs, FinishedMs, DurationMs, TotalLaps, TotalDrivers, Results, WinnerDriverID, WinnerName, BestLapMs, Podium }
|
||||
- **struct RacePlan** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, StartAtMs, IntervalS, Count, Enabled, CreatedMs, UpdatedMs, NextFireMs, FiresDone }
|
||||
- **struct QueueEntry** { DriverID, RaceID, PlanID, EnqueuedMs }
|
||||
- **struct PgStore**
|
||||
- **struct DriverStats** { TotalRacesStarted, TotalRacesFinished, Wins, Podiums, BestLapMs, TotalPlaytimeMs }
|
||||
- **struct DriverRaceSummary** { RaceID, RaceName, FinishedMs, Position, TotalTimeMs, BestLapMs, TrackID, TrackName }
|
||||
|
||||
### Functions
|
||||
- func DecodeCursor(string) (Cursor, error)
|
||||
- func NewLiveStore(*PgStore) *LiveStore
|
||||
- func NewService(*PgStore, *lobby.Service) *Service
|
||||
- func NewScheduler(*PgStore, *lobby.Service, time.Duration) *Scheduler
|
||||
- func NewPgStore(*pgxpool.Pool) *PgStore
|
||||
- func ParseStatusFilter(string) ([]StatusFilter, error)
|
||||
|
||||
### Methods
|
||||
- func (Cursor) String() string
|
||||
- func (Cursor) IsZero() bool
|
||||
- func (Cursor) Encode() string
|
||||
- func (*PgStore) GetTrackLeaderboard(context.Context, string, int, int, int) ([]LeaderboardEntry, int, error)
|
||||
- func (*PgStore) GetTrackLeaderboardDriver(context.Context, string, int, string) (*LeaderboardEntry, error)
|
||||
- func (*PgStore) GetOverallLeaderboard(context.Context, int, int, int) ([]LeaderboardEntry, int, error)
|
||||
- func (*PgStore) GetOverallLeaderboardDriver(context.Context, int, string) (*LeaderboardEntry, error)
|
||||
- func (*PgStore) GetDriverLeaderboardProfile(context.Context, string) (*LeaderboardEntry, error)
|
||||
- func (*PgStore) GetCalendarLeaderboardTracks(context.Context) ([]TrackCalendarInfo, error)
|
||||
- func (*LiveStore) UpsertRace(context.Context, lobby.RaceMeta) error
|
||||
- func (*LiveStore) DeleteRace(context.Context, string) error
|
||||
- func (*LiveStore) AddDriver(context.Context, string, string, int) error
|
||||
- func (*LiveStore) RemoveDriver(context.Context, string, string) error
|
||||
- func (*LiveStore) SetRaceStatus(context.Context, string, lobby.RaceStatus, int64) error
|
||||
- func (*LiveStore) UpsertDriver(context.Context, lobby.DriverMeta) error
|
||||
- func (*LiveStore) DeleteDriver(context.Context, string) error
|
||||
- func (*LiveStore) ListAllRaces(context.Context) ([]lobby.RaceMeta, error)
|
||||
- func (*LiveStore) ListAllDrivers(context.Context) ([]lobby.DriverMeta, error)
|
||||
- func (*LiveStore) ListLivePaged(context.Context, []string, string, Cursor, int) ([]lobby.RaceMeta, error)
|
||||
- func (*LiveStore) ListUpcoming(context.Context, int) ([]lobby.RaceMeta, error)
|
||||
- func (*LiveStore) Exec(context.Context, string) (int64, error)
|
||||
- func (*Service) SetLiveStore(*LiveStore)
|
||||
- func (*Service) RestoreFromDB(context.Context) (int, int, error)
|
||||
- func (*Service) SetMaxUpcoming(int)
|
||||
- func (*Service) ListRaces(context.Context, ListFilter) (ListResult, error)
|
||||
- func (*Service) Upcoming(context.Context, int) ([]UpcomingItem, error)
|
||||
- func (*Service) JoinUpcoming(context.Context, string, int) (JoinUpcomingResult, error)
|
||||
- func (*Service) LeaveQueue(context.Context, string, string) error
|
||||
- func (*Service) ListQueue(context.Context, string) ([]QueueEntry, error)
|
||||
- func (*Service) PersistFinished(context.Context, SnapshotFinished) error
|
||||
- func (*Service) CreatePlan(context.Context, CreatePlanInput) (RacePlan, error)
|
||||
- func (*Service) ListPlans(context.Context, Cursor, int) ([]RacePlan, error)
|
||||
- func (*Service) CountPlans(context.Context) (int, error)
|
||||
- func (*Service) DeletePlan(context.Context, string) error
|
||||
- func (*Service) GetLeaderboard(context.Context, string, int, int, int, string) (*LeaderboardResult, error)
|
||||
- func (*Service) GetCalendarLeaderboard(context.Context, int, string) ([]TrackCalendarLeaderboard, error)
|
||||
- func (*Service) GetRace(context.Context, string) (FinishedRace, error)
|
||||
- func (*Service) GetLastFinished(context.Context) (FinishedRace, error)
|
||||
- func (*Service) GetDriverProfileStats(context.Context, string) (DriverStats, *DriverRaceSummary, *DriverRaceSummary, error)
|
||||
- func (*Scheduler) Run(context.Context)
|
||||
- func (*Scheduler) Stop()
|
||||
- func (*Scheduler) Done() ?
|
||||
- func (*PgStore) Exec(context.Context, string, ?) (int64, error)
|
||||
- func (*PgStore) ListTrackIDs(context.Context) ([]string, error)
|
||||
- func (*PgStore) UpsertLive(context.Context, lobby.RaceMeta) error
|
||||
- func (*PgStore) DeleteLive(context.Context, string) error
|
||||
- func (*PgStore) AddLiveDriver(context.Context, string, string, int) error
|
||||
- func (*PgStore) RemoveLiveDriver(context.Context, string, string) error
|
||||
- func (*PgStore) SetLiveStatus(context.Context, string, lobby.RaceStatus, int64) error
|
||||
- func (*PgStore) ListLivePaged(context.Context, []string, string, Cursor, int) ([]lobby.RaceMeta, error)
|
||||
- func (*PgStore) ListLiveUpcoming(context.Context, int) ([]lobby.RaceMeta, error)
|
||||
- func (*PgStore) GetLive(context.Context, string) (lobby.RaceMeta, error)
|
||||
- func (*PgStore) ListAllRaces(context.Context) ([]lobby.RaceMeta, error)
|
||||
- func (*PgStore) UpsertLobbyDriver(context.Context, lobby.DriverMeta) error
|
||||
- func (*PgStore) DeleteLobbyDriver(context.Context, string) error
|
||||
- func (*PgStore) ListLobbyDrivers(context.Context) ([]lobby.DriverMeta, error)
|
||||
- func (*PgStore) InsertFinished(context.Context, FinishedRace) error
|
||||
- func (*PgStore) ListFinished(context.Context, string, Cursor, int) ([]FinishedRace, error)
|
||||
- func (*PgStore) CountRaces(context.Context, []string, string) (int, error)
|
||||
- func (*PgStore) GetFinished(context.Context, string) (FinishedRace, error)
|
||||
- func (*PgStore) GetLastFinished(context.Context) (FinishedRace, error)
|
||||
- func (*PgStore) CreatePlan(context.Context, RacePlan) error
|
||||
- func (*PgStore) GetPlan(context.Context, string) (RacePlan, error)
|
||||
- func (*PgStore) ListPlans(context.Context, Cursor, int) ([]RacePlan, error)
|
||||
- func (*PgStore) CountPlans(context.Context) (int, error)
|
||||
- func (*PgStore) DeletePlan(context.Context, string) error
|
||||
- func (*PgStore) SetPlanEnabled(context.Context, string, bool) error
|
||||
- func (*PgStore) AdvancePlan(context.Context, string, int64) error
|
||||
- func (*PgStore) DuePlans(context.Context, int64, int) ([]RacePlan, error)
|
||||
- func (*PgStore) Enqueue(context.Context, string, string, string) (QueueEntry, error)
|
||||
- func (*PgStore) Dequeue(context.Context, string, string) error
|
||||
- func (*PgStore) ListQueueByDriver(context.Context, string) ([]QueueEntry, error)
|
||||
- func (*PgStore) ListQueueByRace(context.Context, string) ([]QueueEntry, error)
|
||||
- func (*PgStore) CountQueueByRace(context.Context, string) (int, error)
|
||||
- func (FinishedRace) ToLobbyMeta() lobby.RaceMeta
|
||||
- func (*PgStore) GetDriverStats(context.Context, string) (DriverStats, error)
|
||||
- func (*PgStore) GetDriverLastRace(context.Context, string) (*DriverRaceSummary, error)
|
||||
- func (*PgStore) GetDriverBestRace(context.Context, string) (*DriverRaceSummary, error)
|
||||
|
||||
---
|
||||
|
||||
## Package `main` (`scripts/genseed`)
|
||||
|
||||
Package main contains the genseed utility. It is built into a standalone
|
||||
binary via `go build ./scripts/genseed` and is not part of the runtime.
|
||||
|
||||
Usage:
|
||||
|
||||
go run ./scripts/genseed seeds.json > 002_seed.sql
|
||||
|
||||
It reads the JSON dump produced by `DUMP_SEEDS=1 go test ./internal/catalog`
|
||||
and emits ON CONFLICT-safe INSERTs for the catalog tables.
|
||||
|
||||
---
|
||||
|
||||
## Package `main` (`scripts/genseed-json`)
|
||||
|
||||
Package main contains a small helper that prints the default seeds
|
||||
(5 F1 tracks + 5 F1 cars) as JSON to stdout. It is run as:
|
||||
|
||||
go run ./scripts/genseed-json > seeds.json
|
||||
|
||||
which is then fed into scripts/genseed to regenerate the SQL.
|
||||
|
||||
---
|
||||
|
||||
## Package `main` (`scripts/graphify`)
|
||||
|
||||
Package main is the graphify utility. It walks the codebase, parses Go AST,
|
||||
and outputs a highly compact symbol summary to server/CODEBASE.md.
|
||||
This is used for token savings and helping developers/AI agents quickly map out imports and structures.
|
||||
|
||||
### Structs
|
||||
- **struct PackageSummary** { Name, Path, Doc, Structs, Interfaces, Functions, Methods }
|
||||
|
||||
---
|
||||
|
||||
## Package `auth` (`internal/auth`)
|
||||
|
||||
Package auth handles Supabase JWT validation and HTTP middleware.
|
||||
|
||||
### Structs
|
||||
- **struct Claims** { DriverID, Email }
|
||||
|
||||
### Functions
|
||||
- func ValidateJWT(string, string) (Claims, error)
|
||||
- func Middleware(string, bool) func
|
||||
- func DriverIDFromContext(context.Context) (string, bool)
|
||||
- func EmailFromContext(context.Context) (string, bool)
|
||||
|
||||
---
|
||||
|
||||
## Package `main` (`.`)
|
||||
|
||||
Smoke test: ws client connects, exchanges a few messages, exits.
|
||||
Run: go run ./smoke_test.go
|
||||
|
||||
---
|
||||
|
||||
## Package `seeddefs` (`internal/catalog/seeddefs`)
|
||||
|
||||
Package seeddefs holds the canonical seed definitions for tracks and
|
||||
cars. It lives in its own package (no postgres dependencies) so both
|
||||
the catalog tests and the scripts/genseed SQL generator can consume
|
||||
the same source-of-truth data.
|
||||
|
||||
### Structs
|
||||
- **struct Waypoint** { X, Y, HeadingRad, SpeedMs, Curvature }
|
||||
- **struct Bounds** { MinX, MinY, MaxX, MaxY, LengthM, WidthM }
|
||||
- **struct Track** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline, Bounds, BestLapMs, BestLapHolder }
|
||||
- **struct Chassis** { Model, Material, Printed, WheelbaseMm, TrackMm }
|
||||
- **struct Motor** { Kind, Class, KV, PowerW }
|
||||
- **struct Battery** { VoltageV, CapacityMah, Cells, Chemistry }
|
||||
- **struct Car** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, TopSpeedMs, ColorHex, Active, TotalDistanceM, TotalRaces, TotalLaps, BestLapMs }
|
||||
|
||||
### Functions
|
||||
- func DefaultTracks() []Track
|
||||
- func DefaultCars() []Car
|
||||
- func Monaco() Track
|
||||
- func BarcelonaCatalunya() Track
|
||||
- func AustriaRedBullRing() Track
|
||||
- func GreatBritainSilverstone() Track
|
||||
- func BelgiumSpa() Track
|
||||
- func RedBullRB20() Car
|
||||
- func FerrariSF24() Car
|
||||
- func McLarenMCL38() Car
|
||||
- func MercedesW15() Car
|
||||
- func AstonMartinAMR24() Car
|
||||
- func ComputeBounds([]Waypoint) Bounds
|
||||
|
||||
---
|
||||
|
||||
## Package `control` (`internal/control`)
|
||||
|
||||
Package control owns the authoritative race state.
|
||||
|
||||
For PoC, the physics model is intentionally simple (no tire slip, no
|
||||
collision): each car integrates position from (throttle, brake, steering).
|
||||
Realistic physics, anti-cheat checks, and CV-based ground truth will be
|
||||
added in MVP / production phases.
|
||||
|
||||
### Structs
|
||||
- **struct Car** { ID, DriverID, DriverName, DeviceID, X, Y, Heading, Speed, Lap, Sector, LastLapMs, BestLapMs, DNF, AppliedSteering, AppliedThrottle, AppliedBrake }
|
||||
- **struct Engine**
|
||||
|
||||
### Functions
|
||||
- func NewEngine(int) *Engine
|
||||
|
||||
### Methods
|
||||
- func (RacePhase) String() string
|
||||
- func (*Car) ApplyInput(transport.InputState, float64)
|
||||
- func (*Car) Snapshot() transport.CarInfo
|
||||
- func (*Engine) Snapshots() ?
|
||||
- func (*Engine) Phase() RacePhase
|
||||
- func (*Engine) SetPhase(RacePhase)
|
||||
- func (*Engine) AddCar(string, string, int, *int) (*Car, error)
|
||||
- func (*Engine) RemoveCar(string)
|
||||
- func (*Engine) GetCarByDriver(string) *Car
|
||||
- func (*Engine) Run(context.Context)
|
||||
- func (*Engine) Stop()
|
||||
|
||||
---
|
||||
|
||||
## Package `drivers` (`internal/drivers`)
|
||||
|
||||
Package drivers owns persistence and business logic for drivers
|
||||
(racers). Each driver has a unique 3-letter nickname (A-Z,
|
||||
uppercase, exactly 3 chars), a name, an optional avatar URL and an
|
||||
optional clan reference.
|
||||
|
||||
### Interfaces
|
||||
- **interface Store** { Exec, Create, Get, GetByNickname, List, Delete, Update, Count }
|
||||
|
||||
### Structs
|
||||
- **struct Service**
|
||||
- **struct CreateInput** { ID, Nickname, Name, AvatarURL, ClanID }
|
||||
- **struct UpdateInput** { Nickname, Name, AvatarURL, ClanID }
|
||||
- **struct Driver** { ID, Nickname, Name, AvatarURL, ClanID, CreatedMs, UpdatedMs }
|
||||
- **struct PgStore**
|
||||
|
||||
### Functions
|
||||
- func NewService(Store) *Service
|
||||
- func IsValidNickname(string) bool
|
||||
- func NewPgStore(*pgxpool.Pool) *PgStore
|
||||
|
||||
### Methods
|
||||
- func (*Service) Create(context.Context, CreateInput) (Driver, error)
|
||||
- func (*Service) Get(context.Context, string) (Driver, error)
|
||||
- func (*Service) GetByNickname(context.Context, string) (Driver, error)
|
||||
- func (*Service) List(context.Context, int, int, string) ([]Driver, error)
|
||||
- func (*Service) Update(context.Context, string, UpdateInput) (Driver, error)
|
||||
- func (*Service) Delete(context.Context, string) error
|
||||
- func (*PgStore) Exec(context.Context, string) (int64, error)
|
||||
- func (*PgStore) Create(context.Context, Driver) error
|
||||
- func (*PgStore) Get(context.Context, string) (Driver, error)
|
||||
- func (*PgStore) GetByNickname(context.Context, string) (Driver, error)
|
||||
- func (*PgStore) List(context.Context, int, int, string) ([]Driver, error)
|
||||
- func (*PgStore) Update(context.Context, Driver) error
|
||||
- func (*PgStore) Delete(context.Context, string) error
|
||||
- func (*PgStore) Count(context.Context) (int, error)
|
||||
|
||||
---
|
||||
|
||||
## Package `realtime` (`internal/realtime`)
|
||||
|
||||
Package realtime manages WebSocket connections: registration, fan-out of
|
||||
race snapshots, and per-client send queues with backpressure handling.
|
||||
|
||||
### Structs
|
||||
- **struct Client** { ID, Send, SessionID, Done, DriverID, LastUDPCommand }
|
||||
- **struct Hub**
|
||||
- **struct Stats** { Connections, DropsTotal }
|
||||
|
||||
### Functions
|
||||
- func NewHub() *Hub
|
||||
- func MustEnvelope(transport.MessageType, any) *transport.Envelope
|
||||
|
||||
### Methods
|
||||
- func (*Client) Close()
|
||||
- func (*Hub) Stats() Stats
|
||||
- func (*Hub) Register(*Client)
|
||||
- func (*Hub) Unregister(*Client)
|
||||
- func (*Hub) Run(context.Context)
|
||||
- func (*Hub) Stop()
|
||||
- func (*Hub) Publish(*transport.Envelope)
|
||||
|
||||
---
|
||||
|
||||
## Package `catalog` (`internal/catalog`)
|
||||
|
||||
Package catalog owns the static configuration entities: tracks and
|
||||
cars. Both are first-class resources that drivers browse, pick, and
|
||||
(in later phases) author.
|
||||
|
||||
PoC: in-memory cache backed by a pluggable Store. The runtime uses
|
||||
postgres.PgStore so tracks / cars survive restarts. The cache is the
|
||||
source of truth for read paths; the Store is the source of truth for
|
||||
durability. Mutations go through the Store, then refresh the cache.
|
||||
|
||||
Design constraints come from the user's apartment layout:
|
||||
- largest room: 4.46 m × 3.19 m (446 × 319 cm)
|
||||
- car scale: 1/27 (Compact)
|
||||
- printer: Bambu A1 (220×220×250 mm build volume)
|
||||
|
||||
Tracks are sized to fit a 4.5 × 3.2 m bounding box with a safety
|
||||
margin, which keeps them physically realizable in the apartment.
|
||||
Cars are sized to a 1/27 touring envelope (~80 × 35 × 25 mm).
|
||||
|
||||
### Interfaces
|
||||
- **interface Store** { Load, UpsertTrack, DeleteTrack, UpsertCar, DeleteCar, UpdateCarStats, GetTrackCalendar, CreateTrackCalendar, DeleteTrackCalendar }
|
||||
|
||||
### Structs
|
||||
- **struct CreateTrackOptions** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline }
|
||||
- **struct UpdateTrackOptions** { Name, Description, Visibility, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline }
|
||||
- **struct CreateCarOptions** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, TopSpeedMs, ColorHex, AvatarURL, Active, DeviceID }
|
||||
- **struct UpdateCarOptions** { Name, Visibility, LengthMm, WidthMm, HeightMm, WeightG, TopSpeedMs, ColorHex, Active, Chassis, Motor, Battery, Drive, DeviceID }
|
||||
- **struct Waypoint** { X, Y, HeadingRad, SpeedMs, Curvature }
|
||||
- **struct Bounds** { MinX, MinY, MaxX, MaxY, LengthM, WidthM }
|
||||
- **struct TrackMeta** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Bounds, Surface, Tags, Centerline, BestLapMs, BestLapHolder, CreatedMs, UpdatedMs }
|
||||
- **struct TrackCalendarEntry** { ID, TrackID, StartAt, EndAt }
|
||||
- **struct MotorSpec** { Kind, Class, KV, PowerW }
|
||||
- **struct BatterySpec** { VoltageV, CapacityMah, Cells, Chemistry }
|
||||
- **struct ChassisSpec** { Model, Material, Printed, WheelbaseMm, TrackMm }
|
||||
- **struct CarMeta** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, DeviceID, TopSpeedMs, ColorHex, AvatarURL, Active, TotalDistanceM, TotalRaces, TotalLaps, BestLapMs, CreatedMs, UpdatedMs }
|
||||
- **struct Snapshot** { GeneratedMs, Version, Tracks, Cars }
|
||||
- **struct Stats** { TracksTotal, TracksSystem, TracksPublic, TracksPrivate, CarsTotal, CarsSystem, CarsPublic, CarsPrivate, CarsActive }
|
||||
- **struct Event** { Kind, TrackID, CarID, Snapshot }
|
||||
- **struct Service**
|
||||
|
||||
### Functions
|
||||
- func NewService(context.Context, Store) (*Service, error)
|
||||
|
||||
### Methods
|
||||
- func (CreateTrackOptions) Validate() error
|
||||
- func (*Service) CreateTrack(context.Context, CreateTrackOptions) (TrackMeta, error)
|
||||
- func (*Service) UpdateTrack(context.Context, string, UpdateTrackOptions) (TrackMeta, error)
|
||||
- func (*Service) DeleteTrack(context.Context, string) error
|
||||
- func (CreateCarOptions) Validate() error
|
||||
- func (*Service) CreateCar(context.Context, CreateCarOptions) (CarMeta, error)
|
||||
- func (*Service) UpdateCar(context.Context, string, UpdateCarOptions) (CarMeta, error)
|
||||
- func (*Service) DeleteCar(context.Context, string) error
|
||||
- func (*Service) SetCarStats(context.Context, string, func) error
|
||||
- func (*Service) ListTracksByVisibility(Visibility) []TrackMeta
|
||||
- func (*Service) ListCarsByVisibility(Visibility) []CarMeta
|
||||
- func (*Service) ListCarsByOwner(string) []CarMeta
|
||||
- func (*Service) GetTrackCalendar(context.Context) ([]TrackCalendarEntry, error)
|
||||
- func (*Service) CreateTrackCalendar(context.Context, string, time.Time, time.Time) (TrackCalendarEntry, error)
|
||||
- func (*Service) DeleteTrackCalendar(context.Context, int) error
|
||||
- func (*memoryStore) Load(context.Context) ([]TrackMeta, []CarMeta, error)
|
||||
- func (*memoryStore) UpsertTrack(context.Context, TrackMeta) error
|
||||
- func (*memoryStore) DeleteTrack(context.Context, string) error
|
||||
- func (*memoryStore) UpsertCar(context.Context, CarMeta) error
|
||||
- func (*memoryStore) DeleteCar(context.Context, string) error
|
||||
- func (*memoryStore) UpdateCarStats(context.Context, string, func) error
|
||||
- func (*memoryStore) GetTrackCalendar(context.Context) ([]TrackCalendarEntry, error)
|
||||
- func (*memoryStore) CreateTrackCalendar(context.Context, TrackCalendarEntry) (TrackCalendarEntry, error)
|
||||
- func (*memoryStore) DeleteTrackCalendar(context.Context, int) error
|
||||
- func (*Service) Subscribe() (?, func)
|
||||
- func (*Service) Stop()
|
||||
- func (*Service) Done() ?
|
||||
- func (*Service) Snapshot() Snapshot
|
||||
- func (*Service) Stats() Stats
|
||||
- func (*Service) ListTracks() []TrackMeta
|
||||
- func (*Service) ListCars() []CarMeta
|
||||
- func (*Service) GetTrack(string) (TrackMeta, bool)
|
||||
- func (*Service) GetCar(string) (CarMeta, bool)
|
||||
|
||||
---
|
||||
|
||||
## Package `config` (`internal/config`)
|
||||
|
||||
Package config loads runtime configuration from environment variables.
|
||||
|
||||
On startup Load() reads a .env file (if present) from the current
|
||||
working directory and merges its values into the process environment.
|
||||
Existing process env vars take precedence over .env so deployments
|
||||
can override .env without modifying the file.
|
||||
|
||||
### Structs
|
||||
- **struct Config** { HTTPAddr, TickRate, TickInterval, LogLevel, MaxClients, SnapshotRate, DevMode, DatabaseURL, JWTSecret, UDPVideoAddr, ESPCmdPort }
|
||||
|
||||
### Functions
|
||||
- func Load() (*Config, error)
|
||||
|
||||
---
|
||||
|
||||
## Package `seed` (`internal/races/seed`)
|
||||
|
||||
### Structs
|
||||
- **struct DefaultCounts** { Finished, Live, Plans }
|
||||
- **struct Options** { Counts, Seed, Reset, Now }
|
||||
- **struct Summary** { FinishedInserted, LiveCreated, PlansInserted, QueueInserted, Duration }
|
||||
- **struct Runner**
|
||||
|
||||
### Functions
|
||||
- func NewRunner(*races.PgStore, *lobby.Service, *races.LiveStore, *clans.PgStore, *drivers.PgStore) *Runner
|
||||
|
||||
### Methods
|
||||
- func (*Runner) Run(context.Context, Options) (Summary, error)
|
||||
|
||||
---
|
||||
|
||||
## Package `stats` (`internal/stats`)
|
||||
|
||||
Package stats collects and exposes server and per-driver metrics.
|
||||
|
||||
PoC: in-memory only, atomic counters where possible, mutex around
|
||||
driver and race records. The data lives for the lifetime of the
|
||||
process. A future storage layer will swap this for Postgres/Redis.
|
||||
|
||||
Public surface is split into:
|
||||
- lifecycle hooks (Connect/Disconnect/Race*/Lap*/Input*/RTT) —
|
||||
called from cmd/poc-server and from internal/control and lobby
|
||||
- Snapshot() — full server snapshot for /stats/detailed
|
||||
- DriverProfile(id) — per-driver summary
|
||||
- RecentLaps(n) — last N laps across all races
|
||||
|
||||
### Structs
|
||||
- **struct ServerStats** { StartedMs, UptimeMs, CurrentClients, PeakClients, TotalConnections, TotalDisconnects, TotalRacesCreated, TotalRacesStarted, TotalRacesFinished, TotalLapsRecorded, TotalInputsRecv, TotalSnapshotsSent, SnapshotsDropped, AvgRTTMs, RecentRTTs }
|
||||
- **struct DriverProfile** { ID, Name, TotalRacesStarted, TotalRacesFinished, TotalLaps, BestLapMs, LastLapMs, TotalDistanceM, TotalPlaytimeMs, Wins, Podiums, LastSeenMs }
|
||||
- **struct LapRecord** { RaceID, DriverID, LapMs, Sector1Ms, Sector2Ms, Sector3Ms, Position, TSMs }
|
||||
- **struct RaceResult** { RaceID, DriverID, FinishedAtMs, Position, TotalLaps, BestLapMs, TotalTimeMs, DNF }
|
||||
- **struct Collector**
|
||||
|
||||
### Functions
|
||||
- func NewCollector() *Collector
|
||||
|
||||
### Methods
|
||||
- func (*Collector) Start()
|
||||
- func (*Collector) OnConnect(string, string)
|
||||
- func (*Collector) OnDisconnect(string, int64)
|
||||
- func (*Collector) OnRaceCreated()
|
||||
- func (*Collector) OnRaceStarted([]string)
|
||||
- func (*Collector) OnRaceFinished(RaceResult)
|
||||
- func (*Collector) OnLapRecorded(LapRecord)
|
||||
- func (*Collector) OnInputProcessed()
|
||||
- func (*Collector) OnSnapshotSent(uint64, uint64)
|
||||
- func (*Collector) OnRTT(int64)
|
||||
- func (*Collector) OnDistance(string, float64)
|
||||
- func (*Collector) Server() ServerStats
|
||||
- func (*Collector) Driver(string, string) DriverProfile
|
||||
- func (*Collector) Drivers() []DriverProfile
|
||||
- func (*Collector) RecentLaps(int) []LapRecord
|
||||
- func (*Collector) RecentResults(int) []RaceResult
|
||||
|
||||
---
|
||||
|
||||
## Package `postgres` (`internal/storage/postgres`)
|
||||
|
||||
Package postgres owns the persistence layer of the catalog.
|
||||
|
||||
It exposes a *pgxpool.Pool plus a small set of repository helpers used by
|
||||
internal/catalog. Migrations are applied at startup from the SQL files in
|
||||
the migrations/ subdirectory.
|
||||
|
||||
### Structs
|
||||
- **struct PgStore**
|
||||
- **struct Config** { URL, Host, Port, User, Password, Database, SSLMode, MaxConns, MinConns, ConnectTimeout }
|
||||
|
||||
### Functions
|
||||
- func NewPgStore(*pgxpool.Pool) *PgStore
|
||||
- func Open(context.Context, Config) (*pgxpool.Pool, error)
|
||||
- func Migrate(context.Context, *pgxpool.Pool) error
|
||||
|
||||
### Methods
|
||||
- func (*PgStore) Load(context.Context) ([]catalog.TrackMeta, []catalog.CarMeta, error)
|
||||
- func (*PgStore) UpsertTrack(context.Context, catalog.TrackMeta) error
|
||||
- func (*PgStore) DeleteTrack(context.Context, string) error
|
||||
- func (*PgStore) UpsertCar(context.Context, catalog.CarMeta) error
|
||||
- func (*PgStore) DeleteCar(context.Context, string) error
|
||||
- func (*PgStore) UpdateCarStats(context.Context, string, func) error
|
||||
- func (*PgStore) GetTrackCalendar(context.Context) ([]catalog.TrackCalendarEntry, error)
|
||||
- func (*PgStore) CreateTrackCalendar(context.Context, catalog.TrackCalendarEntry) (catalog.TrackCalendarEntry, error)
|
||||
- func (*PgStore) DeleteTrackCalendar(context.Context, int) error
|
||||
- func (Config) DSN() (string, error)
|
||||
|
||||
---
|
||||
|
||||
## Package `transport` (`internal/transport`)
|
||||
|
||||
Package transport defines the wire format between clients and the server.
|
||||
|
||||
PoC: JSON for fast iteration. Specified by proto/client_server.proto for
|
||||
the production binary protobuf format.
|
||||
|
||||
TODO PoC: replace JSON with protobuf-generated structs (see
|
||||
../proto/client_server.proto).
|
||||
|
||||
### Structs
|
||||
- **struct Envelope** { Type, Seq, TSMs, Payload }
|
||||
- **struct ClientHello** { Version, Token, Locale, ClientID }
|
||||
- **struct ServerHello** { SessionID, RaceTick, ServerVersion, Config }
|
||||
- **struct JoinRace** { RaceID, CarSlot, CarName }
|
||||
- **struct LeaveRace** { RaceID }
|
||||
- **struct InputState** { Steering, Throttle, Brake, Gear, Buttons }
|
||||
- **struct ChatMessage** { Text }
|
||||
- **struct Ping** { ClientTSMs }
|
||||
- **struct RaceSnapshot** { Tick, TSMs, Elapsed, Cars }
|
||||
- **struct CarInfo** { ID, DriverName, X, Y, Heading, Speed, Lap, Sector, LastLapMs, BestLapMs, InputApplied, DNF }
|
||||
- **struct InputAck** { Seq, AppliedAtMs, ServerTickMs }
|
||||
- **struct Correction** { Tick, DeltaSteering, DeltaThrottle, Reason }
|
||||
- **struct RaceEvent** { RaceID, Event, CarID, Sector, LapMs, TSMs }
|
||||
- **struct Pong** { ClientTSMs, ServerTSMs }
|
||||
- **struct ErrorMsg** { Code, Message }
|
||||
- **struct LobbyRace** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, DriverIDs, Status, CreatedMs, StartedMs, Podium }
|
||||
- **struct RacePodiumEntry** { Position, DriverID, Name, TotalTimeMs, Nickname, AvatarUrl, ClanID }
|
||||
- **struct VersionResponse** { Version }
|
||||
- **struct WebRTCConnectRequest** { SDP, Type, DriverID }
|
||||
- **struct WebRTCConnectResponse** { SDP, Type }
|
||||
- **struct LobbyDriver** { ID, Name, Status, RaceID, ConnectedMs, LastSeenMs, Country, AvatarHue }
|
||||
- **struct LobbySnapshot** { GeneratedMs, Version, Races, Drivers }
|
||||
- **struct LobbyCreate** { RaceID, Name, TrackID, MaxCars, Laps, TimeLimitS }
|
||||
- **struct LobbyJoinRace** { RaceID, CarSlot, CarName }
|
||||
- **struct LobbyLeaveRace** { RaceID }
|
||||
- **struct LobbyKickDriver** { RaceID, DriverID }
|
||||
- **struct LobbyDelete** { RaceID }
|
||||
- **struct LobbyCreatedAck** { Ok, Race, Error, Driver }
|
||||
- **struct LobbyJoinAck** { Ok, Race, Error }
|
||||
- **struct TrackWaypoint** { X, Y, HeadingRad, SpeedMs, Curvature }
|
||||
- **struct TrackBounds** { MinX, MinY, MaxX, MaxY, LengthM, WidthM }
|
||||
- **struct TrackWire** { ID, Name, Description, AuthorID, Visibility, Scale, LengthM, WidthM, LaneWidthM, Bounds, Surface, Tags, Centerline, BestLapMs, BestLapHolder, CreatedMs, UpdatedMs, IsActive }
|
||||
- **struct TrackListRequest** { Scope, Tag }
|
||||
- **struct TrackGetRequest** { ID }
|
||||
- **struct TrackCreateRequest** { Track }
|
||||
- **struct TrackUpdateRequest** { ID, Patch }
|
||||
- **struct TrackPatch** { Name, Description, Visibility, Scale, LengthM, WidthM, LaneWidthM, Surface, Tags, Centerline }
|
||||
- **struct TrackDeleteRequest** { ID }
|
||||
- **struct TrackSnapshot** { GeneratedMs, Version, Tracks }
|
||||
- **struct TrackAck** { Ok, ID, Error, Track }
|
||||
- **struct MotorWire** { Kind, Class, KV, PowerW }
|
||||
- **struct BatteryWire** { VoltageV, CapacityMah, Cells, Chemistry }
|
||||
- **struct ChassisWire** { Model, Material, Printed, WheelbaseMm, TrackMm }
|
||||
- **struct CarWire** { ID, Name, OwnerID, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, TopSpeedMs, ColorHex, AvatarURL, Active, TotalDistanceM, TotalRaces, TotalLaps, BestLapMs, CreatedMs, UpdatedMs, DeviceID }
|
||||
- **struct CarListRequest** { Scope, OwnerID }
|
||||
- **struct CarGetRequest** { ID }
|
||||
- **struct CarCreateRequest** { Car }
|
||||
- **struct CarUpdateRequest** { ID, Patch }
|
||||
- **struct CarPatch** { Name, Visibility, Scale, LengthMm, WidthMm, HeightMm, WeightG, Chassis, Motor, Battery, Drive, TopSpeedMs, ColorHex, AvatarURL, Active, DeviceID }
|
||||
- **struct CarDeleteRequest** { ID }
|
||||
- **struct CarSnapshot** { GeneratedMs, Version, Cars }
|
||||
- **struct CarAck** { Ok, ID, Error, Car }
|
||||
- **struct CatalogSummary** { TracksTotal, TracksSystem, TracksPublic, TracksPrivate, CarsTotal, CarsSystem, CarsPublic, CarsPrivate, CarsActive, TSMs }
|
||||
- **struct RaceListResponse** { Items, NextCursor, Count }
|
||||
- **struct RaceUpcomingItem** { Meta, QueueLen, PlanID, StartAtMs }
|
||||
- **struct RaceUpcomingResponse** { Items, Count }
|
||||
- **struct RaceQueueEntry** { DriverID, RaceID, PlanID, EnqueuedMs }
|
||||
- **struct RaceQueueJoinRequest** { DriverID, Limit }
|
||||
- **struct RaceQueueJoinResponse** { Joined, Skipped }
|
||||
- **struct RaceQueueListResponse** { Items, Count }
|
||||
- **struct RacePlan** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, StartAtMs, IntervalS, Count, Enabled, CreatedMs, UpdatedMs, NextFireMs, FiresDone }
|
||||
- **struct RacePlanCreateRequest** { ID, Name, TrackID, MaxCars, Laps, TimeLimitS, StartAtMs, IntervalS, Count, Enabled }
|
||||
- **struct RacePlanListResponse** { Items, NextCursor, Count }
|
||||
- **struct Driver** { ID, Nickname, Name, AvatarURL, ClanID, CreatedMs, UpdatedMs }
|
||||
- **struct DriverCreateRequest** { ID, Nickname, Name, AvatarURL, ClanID }
|
||||
- **struct DriverUpdateRequest** { Nickname, Name, AvatarURL, ClanID }
|
||||
- **struct DriverProfileResponse** { Driver, Stats, LastRace, BestRace }
|
||||
- **struct DriverStats** { TotalRacesStarted, TotalRacesFinished, Wins, Podiums, BestLapMs, TotalPlaytimeMs }
|
||||
- **struct DriverRaceSummary** { RaceID, RaceName, FinishedMs, Position, TotalTimeMs, BestLapMs, TrackID, TrackName }
|
||||
- **struct DriverListResponse** { Items, Count }
|
||||
- **struct Clan** { ID, Tag, Name, AvatarURL, CreatedMs, UpdatedMs }
|
||||
- **struct ClanCreateRequest** { ID, Tag, Name, AvatarURL }
|
||||
- **struct ClanUpdateRequest** { Name, AvatarURL }
|
||||
- **struct ClanListResponse** { Items, Count }
|
||||
- **struct StatsServer** { StartedMs, UptimeMs, CurrentClients, PeakClients, TotalConnections, TotalDisconnects, TotalRacesCreated, TotalRacesStarted, TotalRacesFinished, TotalLapsRecorded, TotalInputsRecv, TotalSnapshotsSent, SnapshotsDropped, AvgRTTMs }
|
||||
- **struct StatsDriver** { ID, Name, TotalRacesStarted, TotalRacesFinished, TotalLaps, BestLapMs, LastLapMs, TotalDistanceM, TotalPlaytimeMs, Wins, Podiums, LastSeenMs }
|
||||
- **struct StatsLap** { RaceID, DriverID, LapMs, Sector1Ms, Sector2Ms, Sector3Ms, Position, TSMs }
|
||||
- **struct StatsRaceResult** { RaceID, DriverID, FinishedAtMs, Position, TotalLaps, BestLapMs, TotalTimeMs, DNF }
|
||||
- **struct StatsSnapshot** { Server, Drivers, Laps, Results, TSMs }
|
||||
- **struct StatsRequest** { Scope, TargetID }
|
||||
- **struct RaceStanding** { Position, CarID, DriverID, DriverName, Lap, Sector, BestLapMs, LastLapMs, GapMs, DNF }
|
||||
- **struct RaceStandingsMsg** { RaceID, Tick, TSMs, Lap, Elapsed, Rows }
|
||||
- **struct LapRecordedMsg** { RaceID, DriverID, Lap, LapMs, Sector1Ms, Sector2Ms, Sector3Ms, TSMs, Position }
|
||||
- **struct RaceResultMsg** { RaceID, DriverID, Position, TotalLaps, BestLapMs, TotalTimeMs, DNF, FinishedAtMs }
|
||||
- **struct LeaderboardEntry** { DriverID, Nickname, Name, AvatarURL, ClanID, ClanTag, Points, BestPos, BestTimeMs, Rank }
|
||||
- **struct LeaderboardResponse** { Items, Total, Limit, Offset, CurrentDriver }
|
||||
- **struct TrackCalendarEntry** { ID, TrackID, StartAt, EndAt }
|
||||
- **struct TrackCalendarCreateRequest** { TrackID, StartAt, EndAt }
|
||||
- **struct TrackCalendarResponse** { Items }
|
||||
- **struct TrackCalendarLeaderboard** { TrackID, TrackName, StartAt, EndAt, Status, Podium, CurrentDriver }
|
||||
- **struct TrackCalendarLeaderboardResponse** { Items }
|
||||
|
||||
### Functions
|
||||
- func Encode(*Envelope) ([]byte, error)
|
||||
- func Decode([]byte) (*Envelope, error)
|
||||
|
||||
---
|
||||
|
||||
@@ -84,4 +84,9 @@ compose-down:
|
||||
clean:
|
||||
rm -rf $(BIN_DIR)
|
||||
|
||||
.PHONY: graphify
|
||||
graphify:
|
||||
$(GO) run ./scripts/graphify/main.go
|
||||
|
||||
.DEFAULT_GOAL := build
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -73,7 +75,7 @@ func boundsToWire(b catalog.Bounds) transport.TrackBounds {
|
||||
}
|
||||
}
|
||||
|
||||
func trackToWire(t catalog.TrackMeta) transport.TrackWire {
|
||||
func trackToWire(t catalog.TrackMeta, activeTracks map[string]bool) transport.TrackWire {
|
||||
return transport.TrackWire{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
@@ -92,20 +94,36 @@ func trackToWire(t catalog.TrackMeta) transport.TrackWire {
|
||||
BestLapHolder: t.BestLapHolder,
|
||||
CreatedMs: t.CreatedMs,
|
||||
UpdatedMs: t.UpdatedMs,
|
||||
IsActive: activeTracks[t.ID],
|
||||
}
|
||||
}
|
||||
|
||||
func tracksToWire(in []catalog.TrackMeta) []transport.TrackWire {
|
||||
func tracksToWire(in []catalog.TrackMeta, activeTracks map[string]bool) []transport.TrackWire {
|
||||
if len(in) == 0 {
|
||||
return []transport.TrackWire{}
|
||||
}
|
||||
out := make([]transport.TrackWire, len(in))
|
||||
for i, t := range in {
|
||||
out[i] = trackToWire(t)
|
||||
out[i] = trackToWire(t, activeTracks)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func getActiveTracks(ctx context.Context, cat *catalog.Service) map[string]bool {
|
||||
active := make(map[string]bool)
|
||||
calendar, err := cat.GetTrackCalendar(ctx)
|
||||
if err != nil {
|
||||
return active
|
||||
}
|
||||
now := time.Now()
|
||||
for _, entry := range calendar {
|
||||
if (now.Equal(entry.StartAt) || now.After(entry.StartAt)) && now.Before(entry.EndAt) {
|
||||
active[entry.TrackID] = true
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
func trackFromWire(w transport.TrackWire) catalog.TrackMeta {
|
||||
return catalog.TrackMeta{
|
||||
ID: w.ID,
|
||||
@@ -171,6 +189,7 @@ func carToWire(c catalog.CarMeta) transport.CarWire {
|
||||
ColorHex: c.ColorHex,
|
||||
AvatarURL: c.AvatarURL,
|
||||
Active: c.Active,
|
||||
DeviceID: c.DeviceID,
|
||||
// Stats are read-mostly; the server fills them on read.
|
||||
TotalDistanceM: c.TotalDistanceM,
|
||||
TotalRaces: c.TotalRaces,
|
||||
@@ -221,8 +240,9 @@ func carFromWire(w transport.CarWire) catalog.CarMeta {
|
||||
ColorHex: w.ColorHex,
|
||||
AvatarURL: w.AvatarURL,
|
||||
Active: w.Active,
|
||||
CreatedMs: w.CreatedMs,
|
||||
UpdatedMs: w.UpdatedMs,
|
||||
CreatedMs: w.CreatedMs,
|
||||
UpdatedMs: w.UpdatedMs,
|
||||
DeviceID: w.DeviceID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +294,7 @@ func carPatchFromWire(p transport.CarPatch) catalog.UpdateCarOptions {
|
||||
Chassis: chassisFromPtr(p.Chassis),
|
||||
Motor: motorFromPtr(p.Motor),
|
||||
Battery: batteryFromPtr(p.Battery),
|
||||
DeviceID: p.DeviceID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,10 +421,11 @@ func mapCatalogError(err error) (int, string) {
|
||||
// @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) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
snap := svc.Snapshot()
|
||||
activeTracks := getActiveTracks(r.Context(), svc)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"tracks": tracksToWire(snap.Tracks),
|
||||
"tracks": tracksToWire(snap.Tracks, activeTracks),
|
||||
"cars": carsToWire(snap.Cars),
|
||||
"summary": summaryToWire(svc.Stats()),
|
||||
})
|
||||
@@ -439,8 +461,9 @@ func tracksHandler(svc *catalog.Service) http.HandlerFunc {
|
||||
if tag != "" {
|
||||
out = filterTracksByTag(out, tag)
|
||||
}
|
||||
activeTracks := getActiveTracks(r.Context(), svc)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"tracks": tracksToWire(out),
|
||||
"tracks": tracksToWire(out, activeTracks),
|
||||
"count": len(out),
|
||||
})
|
||||
}
|
||||
@@ -471,6 +494,7 @@ func trackByIDHandler(svc *catalog.Service) http.HandlerFunc {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing track id")
|
||||
return
|
||||
}
|
||||
activeTracks := getActiveTracks(r.Context(), svc)
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
t, ok := svc.GetTrack(id)
|
||||
@@ -478,7 +502,7 @@ func trackByIDHandler(svc *catalog.Service) http.HandlerFunc {
|
||||
writeError(w, http.StatusNotFound, "not_found", "track not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, trackToWire(t))
|
||||
writeJSON(w, http.StatusOK, trackToWire(t, activeTracks))
|
||||
case http.MethodPut:
|
||||
var req transport.TrackUpdateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -495,7 +519,7 @@ func trackByIDHandler(svc *catalog.Service) http.HandlerFunc {
|
||||
writeError(w, status, code, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated)})
|
||||
writeJSON(w, http.StatusOK, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated, activeTracks)})
|
||||
case http.MethodDelete:
|
||||
if err := svc.DeleteTrack(r.Context(), id); err != nil {
|
||||
status, code := mapCatalogError(err)
|
||||
@@ -549,7 +573,8 @@ func createTrackHandler(svc *catalog.Service) http.HandlerFunc {
|
||||
writeError(w, status, code, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created)})
|
||||
activeTracks := getActiveTracks(r.Context(), svc)
|
||||
writeJSON(w, http.StatusCreated, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created, activeTracks)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,3 +759,121 @@ func carsRouter(svc *catalog.Service) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// trackCalendarRouter routes /api/tracks/calendar to GET or POST
|
||||
func trackCalendarRouter(cat *catalog.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
getTrackCalendarHandler(cat)(w, r)
|
||||
case http.MethodPost:
|
||||
createTrackCalendarHandler(cat)(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getTrackCalendarHandler godoc
|
||||
// @Summary Get Track Calendar
|
||||
// @Description Returns the full calendar of track active periods.
|
||||
// @Tags tracks
|
||||
// @Produce json
|
||||
// @Success 200 {object} transport.TrackCalendarResponse "List of scheduled active periods"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal error"
|
||||
// @Router /api/tracks/calendar [get]
|
||||
func getTrackCalendarHandler(cat *catalog.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := cat.GetTrackCalendar(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]transport.TrackCalendarEntry, len(entries))
|
||||
for i, entry := range entries {
|
||||
items[i] = transport.TrackCalendarEntry{
|
||||
ID: entry.ID,
|
||||
TrackID: entry.TrackID,
|
||||
StartAt: entry.StartAt,
|
||||
EndAt: entry.EndAt,
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, transport.TrackCalendarResponse{Items: items})
|
||||
}
|
||||
}
|
||||
|
||||
// createTrackCalendarHandler godoc
|
||||
// @Summary Schedule Track Active Period
|
||||
// @Description Schedules an active period for a physical track.
|
||||
// @Tags tracks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param payload body transport.TrackCalendarCreateRequest true "Calendar entry details"
|
||||
// @Success 201 {object} transport.TrackCalendarEntry "Created calendar entry"
|
||||
// @Failure 400 {object} map[string]interface{} "Invalid input"
|
||||
// @Failure 404 {object} map[string]interface{} "Track not found"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal error"
|
||||
// @Router /api/tracks/calendar [post]
|
||||
func createTrackCalendarHandler(cat *catalog.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req transport.TrackCalendarCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := cat.CreateTrackCalendar(r.Context(), req.TrackID, req.StartAt, req.EndAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, catalog.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, catalog.ErrInvalidInput) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, transport.TrackCalendarEntry{
|
||||
ID: entry.ID,
|
||||
TrackID: entry.TrackID,
|
||||
StartAt: entry.StartAt,
|
||||
EndAt: entry.EndAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// deleteTrackCalendarHandler godoc
|
||||
// @Summary Delete Scheduled Period
|
||||
// @Description Deletes a scheduled active period by ID.
|
||||
// @Tags tracks
|
||||
// @Param id path int true "Calendar Entry ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 404 {object} map[string]interface{} "Not found"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal error"
|
||||
// @Router /api/tracks/calendar/{id} [delete]
|
||||
func deleteTrackCalendarHandler(cat *catalog.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := strings.TrimPrefix(r.URL.Path, "/api/tracks/calendar/")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_id", "id must be an integer")
|
||||
return
|
||||
}
|
||||
|
||||
err = cat.DeleteTrackCalendar(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, catalog.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,12 +84,13 @@ func (cb *catalogBroadcaster) broadcast(ev catalog.Event) {
|
||||
return
|
||||
case catalog.EventTrackUpsert, catalog.EventTrackDelete:
|
||||
snap := cb.svc.Snapshot()
|
||||
activeTracks := getActiveTracks(context.Background(), cb.svc)
|
||||
cb.hub.Publish(&transport.Envelope{
|
||||
Type: transport.TypeTrackSnapshot,
|
||||
Payload: transport.TrackSnapshot{
|
||||
GeneratedMs: snap.GeneratedMs,
|
||||
Version: snap.Version,
|
||||
Tracks: tracksToWire(snap.Tracks),
|
||||
Tracks: tracksToWire(snap.Tracks, activeTracks),
|
||||
},
|
||||
})
|
||||
cb.hub.Publish(&transport.Envelope{
|
||||
@@ -139,10 +140,11 @@ func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transpo
|
||||
out = filterTracksByTag(out, req.Tag)
|
||||
}
|
||||
snap := svc.Snapshot()
|
||||
activeTracks := getActiveTracks(wsCtx(), svc)
|
||||
sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{
|
||||
GeneratedMs: snap.GeneratedMs,
|
||||
Version: snap.Version,
|
||||
Tracks: tracksToWire(out),
|
||||
Tracks: tracksToWire(out, activeTracks),
|
||||
})
|
||||
case transport.TypeTrackGet:
|
||||
var req transport.TrackGetRequest
|
||||
@@ -156,8 +158,9 @@ func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transpo
|
||||
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: "not_found"})
|
||||
return
|
||||
}
|
||||
activeTracks := getActiveTracks(wsCtx(), svc)
|
||||
sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{
|
||||
Tracks: []transport.TrackWire{trackToWire(t)},
|
||||
Tracks: []transport.TrackWire{trackToWire(t, activeTracks)},
|
||||
})
|
||||
case transport.TypeTrackCreate:
|
||||
var req transport.TrackCreateRequest
|
||||
@@ -173,7 +176,8 @@ func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transpo
|
||||
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)})
|
||||
activeTracks := getActiveTracks(wsCtx(), svc)
|
||||
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created, activeTracks)})
|
||||
case transport.TypeTrackUpdate:
|
||||
var req transport.TrackUpdateRequest
|
||||
_ = json.Unmarshal(payloadBytes, &req)
|
||||
@@ -183,7 +187,8 @@ func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transpo
|
||||
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)})
|
||||
activeTracks := getActiveTracks(wsCtx(), svc)
|
||||
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated, activeTracks)})
|
||||
case transport.TypeTrackDelete:
|
||||
var req transport.TrackDeleteRequest
|
||||
_ = json.Unmarshal(payloadBytes, &req)
|
||||
|
||||
@@ -24,9 +24,12 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/x0gp/server/internal/auth"
|
||||
"github.com/x0gp/server/internal/clans"
|
||||
"github.com/x0gp/server/internal/config"
|
||||
"github.com/x0gp/server/internal/drivers"
|
||||
"github.com/x0gp/server/internal/lobby"
|
||||
"github.com/x0gp/server/internal/races"
|
||||
"github.com/x0gp/server/internal/transport"
|
||||
)
|
||||
|
||||
@@ -283,16 +286,105 @@ func driversListHandler(svc *drivers.Service) http.HandlerFunc {
|
||||
// @Router /api/drivers/{id} [get]
|
||||
// @Router /api/drivers/{id} [put]
|
||||
// @Router /api/drivers/{id} [delete]
|
||||
// @Router /api/drivers/{id}/device [put]
|
||||
func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.HandlerFunc {
|
||||
// @Router /api/drivers/{id}/car [put]
|
||||
func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service, racesSvc *races.Service, cfg *config.Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/drivers/")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing driver id")
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(id, "/device") {
|
||||
driverID := strings.TrimSuffix(id, "/device")
|
||||
|
||||
if strings.HasSuffix(id, "/profile") {
|
||||
driverID := strings.TrimSuffix(id, "/profile")
|
||||
if driverID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing driver id")
|
||||
return
|
||||
}
|
||||
if driverID == "me" {
|
||||
authedID, ok := auth.DriverIDFromContext(r.Context())
|
||||
if !ok {
|
||||
if cfg.DevMode {
|
||||
driverID = "driver-alice"
|
||||
} else {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "missing or invalid auth token")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
driverID = authedID
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method)
|
||||
return
|
||||
}
|
||||
|
||||
drv, err := svc.Get(r.Context(), driverID)
|
||||
if err != nil {
|
||||
if errors.Is(err, drivers.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "driver not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
dbStats, dbLast, dbBest, err := racesSvc.GetDriverProfileStats(r.Context(), driverID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var lastSummary *transport.DriverRaceSummary
|
||||
if dbLast != nil {
|
||||
lastSummary = &transport.DriverRaceSummary{
|
||||
RaceID: dbLast.RaceID,
|
||||
RaceName: dbLast.RaceName,
|
||||
FinishedMs: dbLast.FinishedMs,
|
||||
Position: dbLast.Position,
|
||||
TotalTimeMs: dbLast.TotalTimeMs,
|
||||
BestLapMs: dbLast.BestLapMs,
|
||||
TrackID: dbLast.TrackID,
|
||||
TrackName: dbLast.TrackName,
|
||||
}
|
||||
}
|
||||
|
||||
var bestSummary *transport.DriverRaceSummary
|
||||
if dbBest != nil {
|
||||
bestSummary = &transport.DriverRaceSummary{
|
||||
RaceID: dbBest.RaceID,
|
||||
RaceName: dbBest.RaceName,
|
||||
FinishedMs: dbBest.FinishedMs,
|
||||
Position: dbBest.Position,
|
||||
TotalTimeMs: dbBest.TotalTimeMs,
|
||||
BestLapMs: dbBest.BestLapMs,
|
||||
TrackID: dbBest.TrackID,
|
||||
TrackName: dbBest.TrackName,
|
||||
}
|
||||
}
|
||||
|
||||
profile := transport.DriverProfileResponse{
|
||||
Driver: driverToWire(drv),
|
||||
Stats: transport.DriverStats{
|
||||
TotalRacesStarted: dbStats.TotalRacesStarted,
|
||||
TotalRacesFinished: dbStats.TotalRacesFinished,
|
||||
Wins: dbStats.Wins,
|
||||
Podiums: dbStats.Podiums,
|
||||
BestLapMs: dbStats.BestLapMs,
|
||||
TotalPlaytimeMs: dbStats.TotalPlaytimeMs,
|
||||
},
|
||||
LastRace: lastSummary,
|
||||
BestRace: bestSummary,
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, profile)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(id, "/car") {
|
||||
driverID := strings.TrimSuffix(id, "/car")
|
||||
if driverID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing driver id")
|
||||
return
|
||||
@@ -303,29 +395,26 @@ func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.Hand
|
||||
return
|
||||
}
|
||||
|
||||
// Read device_id from JSON body or query param
|
||||
var deviceID *int
|
||||
type deviceRequest struct {
|
||||
DeviceID *int `json:"device_id"`
|
||||
// Read car_id from JSON body or query param
|
||||
var carID *string
|
||||
type carRequest struct {
|
||||
CarID *string `json:"car_id"`
|
||||
}
|
||||
var req deviceRequest
|
||||
var req carRequest
|
||||
if r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
|
||||
deviceID = req.DeviceID
|
||||
carID = req.CarID
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to query parameter if body didn't provide it
|
||||
if deviceID == nil {
|
||||
qVal := r.URL.Query().Get("device_id")
|
||||
if carID == nil {
|
||||
qVal := r.URL.Query().Get("car_id")
|
||||
if qVal != "" {
|
||||
if qVal == "null" {
|
||||
deviceID = nil
|
||||
} else if idInt, err := strconv.Atoi(qVal); err == nil {
|
||||
deviceID = &idInt
|
||||
carID = nil
|
||||
} else {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid device_id query parameter")
|
||||
return
|
||||
carID = &qVal
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,8 +434,8 @@ func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.Hand
|
||||
_, _ = lobbySvc.AddDriver(driverID, drv.Name, "")
|
||||
lobbySvc.SetDriverProfile(driverID, drv.Nickname, drv.AvatarURL, drv.ClanID, "")
|
||||
|
||||
// Select device in lobby (handles constraints and write-through)
|
||||
if err := lobbySvc.SelectDevice(driverID, deviceID); err != nil {
|
||||
// Select car in lobby (handles constraints and write-through)
|
||||
if err := lobbySvc.SelectCar(driverID, carID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
@@ -392,12 +481,21 @@ func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.Hand
|
||||
}
|
||||
writeJSON(w, http.StatusOK, driverToWire(d))
|
||||
case http.MethodPut:
|
||||
if !cfg.DevMode {
|
||||
authedID, ok := auth.DriverIDFromContext(r.Context())
|
||||
if !ok || authedID != id {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "cannot update another driver's profile")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var req transport.DriverUpdateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
d, err := svc.Update(r.Context(), id, drivers.UpdateInput{
|
||||
Nickname: req.Nickname,
|
||||
Name: req.Name,
|
||||
AvatarURL: req.AvatarURL,
|
||||
ClanID: req.ClanID,
|
||||
@@ -407,11 +505,27 @@ func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.Hand
|
||||
writeError(w, http.StatusNotFound, "not_found", "driver not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, drivers.ErrAlreadyExists) {
|
||||
writeError(w, http.StatusConflict, "conflict", fmt.Sprintf("nickname %s is already taken", req.Nickname))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, drivers.ErrInvalidInput) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, driverToWire(d))
|
||||
case http.MethodDelete:
|
||||
if !cfg.DevMode {
|
||||
authedID, ok := auth.DriverIDFromContext(r.Context())
|
||||
if !ok || authedID != id {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "cannot delete another driver's profile")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.Delete(r.Context(), id); err != nil {
|
||||
if errors.Is(err, drivers.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "driver not found")
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/x0gp/server/internal/races"
|
||||
"github.com/x0gp/server/internal/transport"
|
||||
)
|
||||
|
||||
// leaderboardHandler godoc
|
||||
// @Summary Leaderboard
|
||||
// @Description Returns paginated leaderboard list. Supports overall leaderboard or track-specific leaderboard for the current year.
|
||||
// @Tags leaderboard
|
||||
// @Produce json
|
||||
// @Param track_id query string false "Track ID. If empty, returns overall leaderboard."
|
||||
// @Param year query int false "Year. Defaults to current year."
|
||||
// @Param limit query int false "Limit (1..200, default 50)"
|
||||
// @Param offset query int false "Offset (default 0)"
|
||||
// @Param X-Driver-Id header string false "Current driver ID. Defaults to driver-seed-001 (mock Alice)."
|
||||
// @Success 200 {object} transport.LeaderboardResponse "Leaderboard page and current driver position"
|
||||
// @Failure 400 {object} map[string]interface{} "Invalid input"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/leaderboard [get]
|
||||
func leaderboardHandler(svc *races.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
trackID := r.URL.Query().Get("track_id")
|
||||
|
||||
yearVal := r.URL.Query().Get("year")
|
||||
var year int
|
||||
if yearVal != "" {
|
||||
var err error
|
||||
year, err = strconv.Atoi(yearVal)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", "invalid year")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
limit := 50
|
||||
limitVal := r.URL.Query().Get("limit")
|
||||
if limitVal != "" {
|
||||
var err error
|
||||
limit, err = strconv.Atoi(limitVal)
|
||||
if err != nil || limit <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", "invalid limit")
|
||||
return
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
}
|
||||
|
||||
offset := 0
|
||||
offsetVal := r.URL.Query().Get("offset")
|
||||
if offsetVal != "" {
|
||||
var err error
|
||||
offset, err = strconv.Atoi(offsetVal)
|
||||
if err != nil || offset < 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", "invalid offset")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
driverID := r.Header.Get("X-Driver-Id")
|
||||
if driverID == "" {
|
||||
driverID = "driver-seed-001" // mock current driver
|
||||
}
|
||||
|
||||
res, err := svc.GetLeaderboard(r.Context(), trackID, year, limit, offset, driverID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]transport.LeaderboardEntry, 0, len(res.Items))
|
||||
for _, item := range res.Items {
|
||||
items = append(items, leaderboardEntryToWire(item))
|
||||
}
|
||||
|
||||
var curDriver *transport.LeaderboardEntry
|
||||
if res.CurrentDriver != nil {
|
||||
wire := leaderboardEntryToWire(*res.CurrentDriver)
|
||||
curDriver = &wire
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, transport.LeaderboardResponse{
|
||||
Items: items,
|
||||
Total: res.Total,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
CurrentDriver: curDriver,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func leaderboardEntryToWire(e races.LeaderboardEntry) transport.LeaderboardEntry {
|
||||
var bestPos *int
|
||||
if e.BestPos != nil {
|
||||
pos := *e.BestPos
|
||||
bestPos = &pos
|
||||
}
|
||||
var bestTime *int64
|
||||
if e.BestTimeMs != nil {
|
||||
val := *e.BestTimeMs
|
||||
bestTime = &val
|
||||
}
|
||||
return transport.LeaderboardEntry{
|
||||
DriverID: e.DriverID,
|
||||
Nickname: e.Nickname,
|
||||
Name: e.Name,
|
||||
AvatarURL: e.AvatarURL,
|
||||
ClanID: e.ClanID,
|
||||
ClanTag: e.ClanTag,
|
||||
Points: e.Points,
|
||||
BestPos: bestPos,
|
||||
BestTimeMs: bestTime,
|
||||
Rank: e.Rank,
|
||||
}
|
||||
}
|
||||
|
||||
// calendarLeaderboardHandler godoc
|
||||
// @Summary Track Calendar Leaderboards
|
||||
// @Description Returns the list of scheduled tracks with their status (finished/active/planned), top 3 drivers, and current driver position, sorted by calendar start date.
|
||||
// @Tags leaderboard
|
||||
// @Produce json
|
||||
// @Param year query int false "Year. Defaults to current year."
|
||||
// @Param X-Driver-Id header string false "Current driver ID. Defaults to driver-seed-001 (mock Alice)."
|
||||
// @Success 200 {object} transport.TrackCalendarLeaderboardResponse "Calendar leaderboards list"
|
||||
// @Failure 400 {object} map[string]interface{} "Invalid input"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/leaderboard/tracks [get]
|
||||
func calendarLeaderboardHandler(svc *races.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
yearVal := r.URL.Query().Get("year")
|
||||
var year int
|
||||
if yearVal != "" {
|
||||
var err error
|
||||
year, err = strconv.Atoi(yearVal)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", "invalid year")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
driverID := r.Header.Get("X-Driver-Id")
|
||||
if driverID == "" {
|
||||
driverID = "driver-seed-001" // mock current driver
|
||||
}
|
||||
|
||||
res, err := svc.GetCalendarLeaderboard(r.Context(), year, driverID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]transport.TrackCalendarLeaderboard, 0, len(res))
|
||||
for _, item := range res {
|
||||
podium := make([]transport.LeaderboardEntry, 0, len(item.Podium))
|
||||
for _, entry := range item.Podium {
|
||||
podium = append(podium, leaderboardEntryToWire(entry))
|
||||
}
|
||||
|
||||
var curDriver *transport.LeaderboardEntry
|
||||
if item.CurrentDriver != nil {
|
||||
wire := leaderboardEntryToWire(*item.CurrentDriver)
|
||||
curDriver = &wire
|
||||
}
|
||||
|
||||
items = append(items, transport.TrackCalendarLeaderboard{
|
||||
TrackID: item.TrackID,
|
||||
TrackName: item.TrackName,
|
||||
StartAt: item.StartAt,
|
||||
EndAt: item.EndAt,
|
||||
Status: item.Status,
|
||||
Podium: podium,
|
||||
CurrentDriver: curDriver,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, transport.TrackCalendarLeaderboardResponse{Items: items})
|
||||
}
|
||||
}
|
||||
+207
-59
@@ -27,15 +27,18 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -49,6 +52,7 @@ import (
|
||||
// the // @... annotations on the handlers.
|
||||
_ "github.com/x0gp/server/docs"
|
||||
|
||||
"github.com/x0gp/server/internal/auth"
|
||||
"github.com/x0gp/server/internal/catalog"
|
||||
"github.com/x0gp/server/internal/clans"
|
||||
"github.com/x0gp/server/internal/config"
|
||||
@@ -184,7 +188,7 @@ func main() {
|
||||
|
||||
// Restore the in-memory lobby from Postgres so active races and
|
||||
// driver presence survive a restart.
|
||||
restoreCtx, restoreCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
restoreCtx, restoreCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
if racesRestored, driversRestored, err := racesSvc.RestoreFromDB(restoreCtx); err != nil {
|
||||
logger.Warn("restore from db failed", "err", err)
|
||||
} else {
|
||||
@@ -196,6 +200,9 @@ func main() {
|
||||
clansSvc := clans.NewService(clans.NewPgStore(pool))
|
||||
driversSvc := drivers.NewService(drivers.NewPgStore(pool))
|
||||
|
||||
// WebRTC 1-to-1 driver-car proxy service
|
||||
webrtcSvc := NewWebRTCService(logger, engine, lobbySvc, videoSvc, cat)
|
||||
|
||||
// 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.
|
||||
@@ -280,28 +287,46 @@ func main() {
|
||||
// UDP video and command receiver.
|
||||
videoSvc.Start(ctx, &wg)
|
||||
|
||||
// Start WebRTC service
|
||||
webrtcSvc.Start(ctx, &wg)
|
||||
|
||||
apiMux := http.NewServeMux()
|
||||
apiMux.HandleFunc("/api/version", versionHandler())
|
||||
apiMux.HandleFunc("/api/catalog", catalogHandler(cat))
|
||||
apiMux.HandleFunc("/api/tracks", tracksRouter(cat))
|
||||
apiMux.HandleFunc("/api/tracks/", trackByIDHandler(cat))
|
||||
apiMux.HandleFunc("/api/tracks/calendar", trackCalendarRouter(cat))
|
||||
apiMux.HandleFunc("/api/tracks/calendar/", deleteTrackCalendarHandler(cat))
|
||||
apiMux.HandleFunc("/api/cars", carsRouter(cat))
|
||||
apiMux.HandleFunc("/api/cars/", carByIDHandler(cat))
|
||||
apiMux.HandleFunc("/api/races", racesListHandler(racesSvc))
|
||||
apiMux.HandleFunc("/api/races/last", racesGetLastHandler(racesSvc))
|
||||
apiMux.HandleFunc("/api/races/", racesGetHandler(racesSvc))
|
||||
apiMux.HandleFunc("/api/races/upcoming", racesUpcomingHandler(racesSvc))
|
||||
apiMux.HandleFunc("/api/races/queue", racesQueueRouter(racesSvc))
|
||||
apiMux.HandleFunc("/api/races/queue/join", racesQueueJoinHandler(racesSvc))
|
||||
apiMux.HandleFunc("/api/races/plans", racePlansRouter(racesSvc))
|
||||
apiMux.HandleFunc("/api/races/plans/", racePlansDeleteHandler(racesSvc))
|
||||
apiMux.HandleFunc("/api/leaderboard", leaderboardHandler(racesSvc))
|
||||
apiMux.HandleFunc("/api/leaderboard/tracks", calendarLeaderboardHandler(racesSvc))
|
||||
apiMux.HandleFunc("/api/clans", clansRouter(clansSvc))
|
||||
apiMux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc))
|
||||
apiMux.HandleFunc("/api/drivers", driversRouter(driversSvc))
|
||||
apiMux.HandleFunc("/api/drivers/", driversByIDHandler(driversSvc, lobbySvc, racesSvc, cfg))
|
||||
apiMux.HandleFunc("/api/video/stream", videoSvc.StreamHandler())
|
||||
apiMux.HandleFunc("/api/video/control", videoSvc.ControlHandler())
|
||||
apiMux.HandleFunc("/api/webrtc/connect", webrtcSvc.ConnectHandler())
|
||||
|
||||
authMW := auth.Middleware(cfg.JWTSecret, cfg.DevMode)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", healthHandler(hub, engine))
|
||||
mux.HandleFunc("/api/version", versionHandler())
|
||||
mux.HandleFunc("/stats", statsHandler(hub, engine))
|
||||
mux.HandleFunc("/api/catalog", catalogHandler(cat))
|
||||
mux.HandleFunc("/api/tracks", tracksRouter(cat))
|
||||
mux.HandleFunc("/api/tracks/", trackByIDHandler(cat))
|
||||
mux.HandleFunc("/api/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, lobbySvc))
|
||||
mux.HandleFunc("/api/video/stream", videoSvc.StreamHandler())
|
||||
mux.HandleFunc("/api/video/control", videoSvc.ControlHandler())
|
||||
mux.HandleFunc("/ws", wsHandler(cfg, engine, hub, cat, lobbySvc, driversSvc, videoSvc, logger))
|
||||
mux.Handle("/api/", authMW(apiMux))
|
||||
mux.HandleFunc("/ws", wsHandler(cfg, engine, hub, cat, lobbySvc, driversSvc, clansSvc, videoSvc, logger))
|
||||
|
||||
// Static client files (resolving TODO)
|
||||
mux.Handle("/", http.FileServer(http.Dir("./web")))
|
||||
|
||||
// Swagger UI + OpenAPI spec.
|
||||
// UI: GET /swagger/index.html
|
||||
@@ -467,7 +492,7 @@ func statsHandler(h *Hub, e *control.Engine) http.HandlerFunc {
|
||||
// @Tags websocket
|
||||
// @Success 101 {object} transport.Envelope "Switching protocols. Subsequent frames follow the envelope schema."
|
||||
// @Router /ws [get]
|
||||
func wsHandler(cfg *config.Config, e *control.Engine, h *Hub, cat *catalog.Service, lobbySvc *lobby.Service, driversSvc *drivers.Service, videoSvc *UDPVideoService, logger *slog.Logger) http.HandlerFunc {
|
||||
func wsHandler(cfg *config.Config, e *control.Engine, h *Hub, cat *catalog.Service, lobbySvc *lobby.Service, driversSvc *drivers.Service, clansSvc *clans.Service, videoSvc *UDPVideoService, logger *slog.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
@@ -485,7 +510,7 @@ func wsHandler(cfg *config.Config, e *control.Engine, h *Hub, cat *catalog.Servi
|
||||
"remote", r.RemoteAddr,
|
||||
)
|
||||
go writePump(client, conn, logger)
|
||||
go readPump(cfg, client, conn, e, h, cat, lobbySvc, driversSvc, videoSvc, logger)
|
||||
go readPump(cfg, client, conn, e, h, cat, lobbySvc, driversSvc, clansSvc, videoSvc, logger)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,7 +552,7 @@ func writePump(c *realtime.Client, conn *websocket.Conn, logger *slog.Logger) {
|
||||
}
|
||||
}
|
||||
|
||||
func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *control.Engine, h *Hub, cat *catalog.Service, lobbySvc *lobby.Service, driversSvc *drivers.Service, videoSvc *UDPVideoService, logger *slog.Logger) {
|
||||
func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *control.Engine, h *Hub, cat *catalog.Service, lobbySvc *lobby.Service, driversSvc *drivers.Service, clansSvc *clans.Service, videoSvc *UDPVideoService, logger *slog.Logger) {
|
||||
defer func() {
|
||||
h.Unregister(c)
|
||||
logger.Info("client disconnected", "client_id", c.ID)
|
||||
@@ -559,9 +584,9 @@ func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *c
|
||||
|
||||
switch env.Type {
|
||||
case transport.TypeClientHello:
|
||||
handleClientHello(c, e, cfg, lobbySvc, driversSvc, env)
|
||||
handleClientHello(c, e, cfg, lobbySvc, driversSvc, clansSvc, env)
|
||||
case transport.TypeJoinRace:
|
||||
handleJoinRace(c, e, env)
|
||||
handleJoinRace(c, e, lobbySvc, cat, env)
|
||||
case transport.TypeLeaveRace:
|
||||
handleLeaveRace(c, e, env)
|
||||
case transport.TypeInputState:
|
||||
@@ -590,7 +615,7 @@ func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *c
|
||||
|
||||
// Message handlers --------------------------------------------------------
|
||||
|
||||
func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config, lobbySvc *lobby.Service, driversSvc *drivers.Service, env *transport.Envelope) {
|
||||
func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config, lobbySvc *lobby.Service, driversSvc *drivers.Service, clansSvc *clans.Service, env *transport.Envelope) {
|
||||
hello := transport.ServerHello{
|
||||
SessionID: c.ID,
|
||||
RaceTick: 0,
|
||||
@@ -601,25 +626,114 @@ func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config
|
||||
hello.Config.MaxCars = 4
|
||||
c.SessionID = c.ID
|
||||
|
||||
// Extract client_id (DriverID) if present in payload
|
||||
payload, _ := env.Payload.(map[string]any)
|
||||
if payload != nil {
|
||||
if clientID, ok := payload["client_id"].(string); ok && clientID != "" {
|
||||
c.DriverID = clientID
|
||||
// Pre-populate driver in the lobby
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
if drv, err := driversSvc.Get(ctx, clientID); err == nil {
|
||||
_, _ = lobbySvc.AddDriver(clientID, drv.Name, "")
|
||||
lobbySvc.SetDriverProfile(clientID, drv.Nickname, drv.AvatarURL, drv.ClanID, "")
|
||||
}
|
||||
cancel()
|
||||
var clientID string
|
||||
var email string
|
||||
|
||||
if cfg.DevMode {
|
||||
// In devMode, client_id is optional and can be arbitrary
|
||||
if cid, ok := payload["client_id"].(string); ok && cid != "" {
|
||||
clientID = cid
|
||||
} else {
|
||||
clientID = c.ID
|
||||
}
|
||||
} else {
|
||||
// In production, require valid Supabase JWT
|
||||
tokenStr, _ := payload["token"].(string)
|
||||
if tokenStr == "" {
|
||||
sendError(c, "unauthorized", "missing auth token in client_hello")
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := auth.ValidateJWT(tokenStr, cfg.JWTSecret)
|
||||
if err != nil {
|
||||
sendError(c, "unauthorized", fmt.Sprintf("invalid auth token: %v", err))
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
clientID = claims.DriverID
|
||||
email = claims.Email
|
||||
}
|
||||
|
||||
c.DriverID = clientID
|
||||
|
||||
// Resolve/ensure profile and join lobby
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
drv, clanTag, err := ensureDriverProfile(ctx, clientID, email, driversSvc, clansSvc)
|
||||
if err == nil {
|
||||
_, _ = lobbySvc.AddDriver(clientID, drv.Name, "")
|
||||
lobbySvc.SetDriverProfile(clientID, drv.Nickname, drv.AvatarURL, drv.ClanID, clanTag)
|
||||
} else {
|
||||
// Fallback placeholder profile if database error
|
||||
_, _ = lobbySvc.AddDriver(clientID, "Driver", "")
|
||||
lobbySvc.SetDriverProfile(clientID, "DRV", "", "", "")
|
||||
}
|
||||
|
||||
c.Send <- mustEncode(transport.TypeServerHello, hello)
|
||||
}
|
||||
|
||||
func handleJoinRace(c *realtime.Client, e *control.Engine, env *transport.Envelope) {
|
||||
func ensureDriverProfile(ctx context.Context, id string, email string, driversSvc *drivers.Service, clansSvc *clans.Service) (drivers.Driver, string, error) {
|
||||
drv, err := driversSvc.Get(ctx, id)
|
||||
if err == nil {
|
||||
var clanTag string
|
||||
if drv.ClanID != "" {
|
||||
if cl, err := clansSvc.Get(ctx, drv.ClanID); err == nil {
|
||||
clanTag = cl.Tag
|
||||
}
|
||||
}
|
||||
return drv, clanTag, nil
|
||||
}
|
||||
|
||||
// Profile not found: auto-create a default profile
|
||||
name := "Driver"
|
||||
if email != "" {
|
||||
if idx := strings.Index(email, "@"); idx > 0 {
|
||||
name = email[:idx]
|
||||
}
|
||||
}
|
||||
// Generate unique 3-letter nickname
|
||||
nick := "DRV"
|
||||
if len(name) >= 3 {
|
||||
candidate := strings.ToUpper(name[:3])
|
||||
if isAlphaOnly(candidate) {
|
||||
nick = candidate
|
||||
}
|
||||
}
|
||||
|
||||
// Check if nickname already exists
|
||||
for i := 0; i < 20; i++ {
|
||||
_, err := driversSvc.GetByNickname(ctx, nick)
|
||||
if err != nil && errors.Is(err, drivers.ErrNotFound) {
|
||||
break
|
||||
}
|
||||
nick = fmt.Sprintf("D%02d", i)
|
||||
}
|
||||
|
||||
created, err := driversSvc.Create(ctx, drivers.CreateInput{
|
||||
ID: id,
|
||||
Nickname: nick,
|
||||
Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
return drivers.Driver{}, "", err
|
||||
}
|
||||
return created, "", nil
|
||||
}
|
||||
|
||||
func isAlphaOnly(s string) bool {
|
||||
for _, c := range s {
|
||||
if (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
func handleJoinRace(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service, cat *catalog.Service, env *transport.Envelope) {
|
||||
payload, _ := env.Payload.(map[string]any)
|
||||
slot := 0
|
||||
name := "anon"
|
||||
@@ -631,7 +745,20 @@ func handleJoinRace(c *realtime.Client, e *control.Engine, env *transport.Envelo
|
||||
name = v
|
||||
}
|
||||
}
|
||||
car, err := e.AddCar(c.ID, name, slot)
|
||||
|
||||
// Resolve DeviceID for this driver internally
|
||||
driverID := c.DriverID
|
||||
if driverID == "" {
|
||||
driverID = c.ID
|
||||
}
|
||||
var deviceID *int
|
||||
if d, err := lobbySvc.GetDriver(driverID); err == nil && d.CarID != nil && *d.CarID != "" {
|
||||
if carMeta, ok := cat.GetCar(*d.CarID); ok {
|
||||
deviceID = carMeta.DeviceID
|
||||
}
|
||||
}
|
||||
|
||||
car, err := e.AddCar(c.ID, name, slot, deviceID)
|
||||
if err != nil {
|
||||
sendError(c, "join_failed", err.Error())
|
||||
return
|
||||
@@ -660,33 +787,54 @@ func handleInput(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service,
|
||||
if v, ok := payload["gear"].(float64); ok {
|
||||
in.Gear = int(v)
|
||||
}
|
||||
if v, ok := payload["buttons"].(float64); ok {
|
||||
in.Buttons = uint32(v)
|
||||
}
|
||||
car := e.GetCarByDriver(c.ID)
|
||||
if car == nil {
|
||||
return
|
||||
}
|
||||
car.ApplyInput(in, 1.0/60.0)
|
||||
|
||||
// Forward command transitions to ESP32 physical car if driver is assigned one
|
||||
driverID := c.DriverID
|
||||
if driverID == "" {
|
||||
driverID = c.ID
|
||||
}
|
||||
if d, err := lobbySvc.GetDriver(driverID); err == nil && d.DeviceID != nil {
|
||||
var cmd string
|
||||
if in.Throttle > 0.1 {
|
||||
cmd = "start"
|
||||
} else {
|
||||
cmd = "stop"
|
||||
}
|
||||
if in.Steering < -0.2 {
|
||||
cmd = "left"
|
||||
} else if in.Steering > 0.2 {
|
||||
cmd = "right"
|
||||
}
|
||||
// Forward command transitions to ESP32 physical car if car has a device_id assigned
|
||||
if car.DeviceID != nil {
|
||||
var packet [5]byte
|
||||
|
||||
if cmd != "" && cmd != c.LastUDPCommand {
|
||||
c.LastUDPCommand = cmd
|
||||
_ = videoSvc.SendCommand(uint8(*d.DeviceID), cmd)
|
||||
// 1. Steer: mapping float64 [-1.0, 1.0] to [0..200] (100 is center)
|
||||
steerVal := int(math.Round((in.Steering + 1.0) * 100.0))
|
||||
if steerVal < 0 { steerVal = 0 }
|
||||
if steerVal > 200 { steerVal = 200 }
|
||||
packet[0] = uint8(steerVal)
|
||||
|
||||
// 2. Throttle: mapping float64 [0.0, 1.0] to [0..100]
|
||||
throttleVal := int(math.Round(in.Throttle * 100.0))
|
||||
if throttleVal < 0 { throttleVal = 0 }
|
||||
if throttleVal > 100 { throttleVal = 100 }
|
||||
packet[1] = uint8(throttleVal)
|
||||
|
||||
// 3. Brake: mapping float64 [0.0, 1.0] to [0..100]
|
||||
brakeVal := int(math.Round(in.Brake * 100.0))
|
||||
if brakeVal < 0 { brakeVal = 0 }
|
||||
if brakeVal > 100 { brakeVal = 100 }
|
||||
packet[2] = uint8(brakeVal)
|
||||
|
||||
// 4. Gear: map -1 to 0 (R), 0 to 1 (N), >=1 to 2 (D)
|
||||
gearVal := 1 // Neutral by default
|
||||
if in.Gear < 0 {
|
||||
gearVal = 0 // R
|
||||
} else if in.Gear > 0 {
|
||||
gearVal = 2 // D
|
||||
}
|
||||
packet[3] = uint8(gearVal)
|
||||
|
||||
// 5. Flags: bit 0 (DRS), bit 1 (Pit Limiter)
|
||||
packet[4] = uint8(in.Buttons & 0x03)
|
||||
|
||||
// Deduplicate UDP transmissions
|
||||
hexCmd := hex.EncodeToString(packet[:])
|
||||
if hexCmd != c.LastUDPCommand {
|
||||
c.LastUDPCommand = hexCmd
|
||||
_ = videoSvc.SendControlPacket(uint8(*car.DeviceID), packet)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,100 @@ func racesListHandler(svc *races.Service) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// racesGetLastHandler godoc
|
||||
// @Summary Get the results of the last finished race
|
||||
// @Description Returns the details and podium/results of the most recently finished race.
|
||||
// @Tags races
|
||||
// @Produce json
|
||||
// @Success 200 {object} transport.LobbyRace "Last race results"
|
||||
// @Failure 404 {object} map[string]interface{} "No finished races found"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/races/last [get]
|
||||
func racesGetLastHandler(svc *races.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := svc.GetLastFinished(r.Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, races.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no finished races found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var out transport.LobbyRace
|
||||
meta := lobby.RaceMeta{
|
||||
ID: res.ID,
|
||||
Name: res.Name,
|
||||
TrackID: res.TrackID,
|
||||
MaxCars: res.MaxCars,
|
||||
Laps: res.Laps,
|
||||
TimeLimitS: res.TimeLimitS,
|
||||
DriverIDs: res.DriverIDs,
|
||||
Status: lobby.RaceStatus(res.Status),
|
||||
CreatedMs: res.CreatedMs,
|
||||
StartedMs: res.StartedMs,
|
||||
}
|
||||
if len(res.Podium) > 0 {
|
||||
out = lobbyToWireFinished(meta, res.Podium)
|
||||
} else {
|
||||
out = lobbyToWire(meta)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// racesGetHandler godoc
|
||||
// @Summary Get results of a specific race by ID
|
||||
// @Description Returns details of a single race by its ID. Works for both live and finished races.
|
||||
// @Tags races
|
||||
// @Produce json
|
||||
// @Param id path string true "Race ID"
|
||||
// @Success 200 {object} transport.LobbyRace "Race details"
|
||||
// @Failure 400 {object} map[string]interface{} "Missing race id"
|
||||
// @Failure 404 {object} map[string]interface{} "Race not found"
|
||||
// @Failure 500 {object} map[string]interface{} "Internal server error"
|
||||
// @Router /api/races/{id} [get]
|
||||
func racesGetHandler(svc *races.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/races/")
|
||||
if id == "" || strings.Contains(id, "/") {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing race id")
|
||||
return
|
||||
}
|
||||
res, err := svc.GetRace(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, races.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "race not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var out transport.LobbyRace
|
||||
meta := lobby.RaceMeta{
|
||||
ID: res.ID,
|
||||
Name: res.Name,
|
||||
TrackID: res.TrackID,
|
||||
MaxCars: res.MaxCars,
|
||||
Laps: res.Laps,
|
||||
TimeLimitS: res.TimeLimitS,
|
||||
DriverIDs: res.DriverIDs,
|
||||
Status: lobby.RaceStatus(res.Status),
|
||||
CreatedMs: res.CreatedMs,
|
||||
StartedMs: res.StartedMs,
|
||||
}
|
||||
if len(res.Podium) > 0 {
|
||||
out = lobbyToWireFinished(meta, res.Podium)
|
||||
} else {
|
||||
out = lobbyToWire(meta)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func parseRaceListFilter(r *http.Request) (races.ListFilter, error) {
|
||||
q := r.URL.Query()
|
||||
cur, err := races.DecodeCursor(q.Get("cursor"))
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -172,7 +173,6 @@ func (s *UDPVideoService) Start(ctx context.Context, wg *sync.WaitGroup) {
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
}
|
||||
|
||||
if !hasDevice {
|
||||
s.logger.Warn("cannot send command: no ESP32 devices discovered yet")
|
||||
continue
|
||||
@@ -188,13 +188,12 @@ func (s *UDPVideoService) Start(ctx context.Context, wg *sync.WaitGroup) {
|
||||
}()
|
||||
}
|
||||
|
||||
// SendCommand sends a command string to the ESP32 representing deviceID.
|
||||
func (s *UDPVideoService) SendCommand(deviceID uint8, cmd string) error {
|
||||
// SendControlPacket sends a 5-byte binary control packet to the ESP32 representing deviceID.
|
||||
func (s *UDPVideoService) SendControlPacket(deviceID uint8, packet [5]byte) error {
|
||||
s.mu.RLock()
|
||||
dev, ok := s.devices[deviceID]
|
||||
conn := s.conn
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok || dev.addr == nil {
|
||||
return fmt.Errorf("ESP32 device %d address unknown (no video packets received yet)", deviceID)
|
||||
}
|
||||
@@ -207,10 +206,35 @@ func (s *UDPVideoService) SendCommand(deviceID uint8, cmd string) error {
|
||||
Port: s.espCmdPort,
|
||||
}
|
||||
|
||||
_, err := conn.WriteToUDP([]byte(cmd), cmdAddr)
|
||||
_, err := conn.WriteToUDP(packet[:], cmdAddr)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendCommand sends a command string to the ESP32 representing deviceID by mapping it to a 5-byte packet.
|
||||
func (s *UDPVideoService) SendCommand(deviceID uint8, cmd string) error {
|
||||
var packet [5]byte
|
||||
packet[0] = 100 // Steer: center
|
||||
packet[1] = 0 // Throttle: 0%
|
||||
packet[2] = 0 // Brake: 0%
|
||||
packet[3] = 1 // Gear: Neutral
|
||||
packet[4] = 0 // Flags: 0
|
||||
|
||||
switch cmd {
|
||||
case "start":
|
||||
packet[1] = 100 // Throttle: 100%
|
||||
packet[3] = 2 // Gear: Drive
|
||||
case "stop":
|
||||
packet[2] = 100 // Brake: 100%
|
||||
packet[3] = 1 // Gear: Neutral
|
||||
case "left":
|
||||
packet[0] = 0 // Steer: full left
|
||||
case "right":
|
||||
packet[0] = 200 // Steer: full right
|
||||
}
|
||||
|
||||
return s.SendControlPacket(deviceID, packet)
|
||||
}
|
||||
|
||||
func (s *UDPVideoService) broadcastFrame(deviceID uint8, frame []byte) {
|
||||
// dev.latestFrame is updated inside the caller's lock (Start loop) to avoid races
|
||||
if dev, ok := s.devices[deviceID]; ok {
|
||||
@@ -328,13 +352,14 @@ func (s *UDPVideoService) StreamHandler() http.HandlerFunc {
|
||||
|
||||
// ControlHandler godoc
|
||||
// @Summary Send command to ESP32
|
||||
// @Description Sends a text command (e.g. start, stop, left, right) to the ESP32 command port (UDP 8888).
|
||||
// @Description Sends a text command or a JSON control packet to the ESP32 command port (UDP 8888).
|
||||
// @Tags video
|
||||
// @Param id query int false "Device ID (0..127)"
|
||||
// @Param cmd query string true "Command text"
|
||||
// @Param cmd query string false "Command text (legacy)"
|
||||
// @Param body body map[string]int false "Control packet JSON (steer, throttle, brake, gear, flags)"
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{} "Command sent"
|
||||
// @Failure 400 {string} string "Missing cmd parameter"
|
||||
// @Failure 400 {string} string "Missing parameter or invalid body"
|
||||
// @Failure 500 {string} string "Internal error"
|
||||
// @Router /api/video/control [post]
|
||||
func (s *UDPVideoService) ControlHandler() http.HandlerFunc {
|
||||
@@ -343,14 +368,6 @@ func (s *UDPVideoService) ControlHandler() http.HandlerFunc {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed")
|
||||
return
|
||||
}
|
||||
cmd := r.URL.Query().Get("cmd")
|
||||
if cmd == "" {
|
||||
cmd = r.FormValue("cmd")
|
||||
}
|
||||
if cmd == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Missing 'cmd' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
idVal := r.URL.Query().Get("id")
|
||||
if idVal == "" {
|
||||
@@ -366,7 +383,109 @@ func (s *UDPVideoService) ControlHandler() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
err := s.SendCommand(deviceID, cmd)
|
||||
var packet [5]byte
|
||||
packet[0] = 100 // Steer: center
|
||||
packet[1] = 0 // Throttle: 0%
|
||||
packet[2] = 0 // Brake: 0%
|
||||
packet[3] = 1 // Gear: Neutral
|
||||
packet[4] = 0 // Flags: 0
|
||||
|
||||
isJSON := strings.Contains(r.Header.Get("Content-Type"), "application/json")
|
||||
var cmdUsed string
|
||||
|
||||
if isJSON {
|
||||
var req struct {
|
||||
Steer *int `json:"steer"`
|
||||
Throttle *int `json:"throttle"`
|
||||
Brake *int `json:"brake"`
|
||||
Gear *int `json:"gear"`
|
||||
Flags *int `json:"flags"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid JSON payload")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Steer != nil {
|
||||
val := *req.Steer
|
||||
if val < 0 {
|
||||
val = 0
|
||||
}
|
||||
if val > 200 {
|
||||
val = 200
|
||||
}
|
||||
packet[0] = uint8(val)
|
||||
}
|
||||
if req.Throttle != nil {
|
||||
val := *req.Throttle
|
||||
if val < 0 {
|
||||
val = 0
|
||||
}
|
||||
if val > 100 {
|
||||
val = 100
|
||||
}
|
||||
packet[1] = uint8(val)
|
||||
}
|
||||
if req.Brake != nil {
|
||||
val := *req.Brake
|
||||
if val < 0 {
|
||||
val = 0
|
||||
}
|
||||
if val > 100 {
|
||||
val = 100
|
||||
}
|
||||
packet[2] = uint8(val)
|
||||
}
|
||||
if req.Gear != nil {
|
||||
val := *req.Gear
|
||||
if val < 0 {
|
||||
val = 0
|
||||
}
|
||||
if val > 2 {
|
||||
val = 2
|
||||
}
|
||||
packet[3] = uint8(val)
|
||||
}
|
||||
if req.Flags != nil {
|
||||
val := *req.Flags
|
||||
if val < 0 {
|
||||
val = 0
|
||||
}
|
||||
if val > 255 {
|
||||
val = 255
|
||||
}
|
||||
packet[4] = uint8(val)
|
||||
}
|
||||
cmdUsed = fmt.Sprintf("json(steer=%d,throttle=%d,brake=%d,gear=%d,flags=%d)", packet[0], packet[1], packet[2], packet[3], packet[4])
|
||||
} else {
|
||||
cmd := r.URL.Query().Get("cmd")
|
||||
if cmd == "" {
|
||||
cmd = r.FormValue("cmd")
|
||||
}
|
||||
if cmd == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Missing 'cmd' parameter")
|
||||
return
|
||||
}
|
||||
cmdUsed = cmd
|
||||
|
||||
switch cmd {
|
||||
case "start":
|
||||
packet[1] = 100
|
||||
packet[3] = 2
|
||||
case "stop":
|
||||
packet[2] = 100
|
||||
packet[3] = 1
|
||||
case "left":
|
||||
packet[0] = 0
|
||||
case "right":
|
||||
packet[0] = 200
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Unknown command: "+cmd)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err := s.SendControlPacket(deviceID, packet)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
||||
return
|
||||
@@ -375,7 +494,8 @@ func (s *UDPVideoService) ControlHandler() http.HandlerFunc {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "success",
|
||||
"device_id": deviceID,
|
||||
"command": cmd,
|
||||
"command": cmdUsed,
|
||||
"packet": packet[:],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/x0gp/server/internal/catalog"
|
||||
"github.com/x0gp/server/internal/control"
|
||||
"github.com/x0gp/server/internal/lobby"
|
||||
"github.com/x0gp/server/internal/transport"
|
||||
)
|
||||
|
||||
type driverSession struct {
|
||||
driverID string
|
||||
deviceID uint8
|
||||
pc *webrtc.PeerConnection
|
||||
videoDC *webrtc.DataChannel
|
||||
dataDC *webrtc.DataChannel
|
||||
lastCommand string
|
||||
cancelCtx context.CancelFunc
|
||||
}
|
||||
|
||||
type WebRTCService struct {
|
||||
logger *slog.Logger
|
||||
engine *control.Engine
|
||||
lobbySvc *lobby.Service
|
||||
videoSvc *UDPVideoService
|
||||
catalogSvc *catalog.Service
|
||||
sessions map[string]*driverSession
|
||||
sessionsMu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewWebRTCService(logger *slog.Logger, e *control.Engine, lobbySvc *lobby.Service, videoSvc *UDPVideoService, catalogSvc *catalog.Service) *WebRTCService {
|
||||
return &WebRTCService{
|
||||
logger: logger,
|
||||
engine: e,
|
||||
lobbySvc: lobbySvc,
|
||||
videoSvc: videoSvc,
|
||||
catalogSvc: catalogSvc,
|
||||
sessions: make(map[string]*driverSession),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebRTCService) Start(ctx context.Context, wg *sync.WaitGroup) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.CloseAll()
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.broadcastTelemetry()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ConnectHandler godoc
|
||||
// @Summary Establish WebRTC connection for driver-car 1-to-1 proxying
|
||||
// @Description Accepts SDP Offer, negotiates peer connection, and returns SDP Answer. Routes video and control data via DataChannels.
|
||||
// @Tags webrtc
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body transport.WebRTCConnectRequest true "SDP Offer details"
|
||||
// @Success 200 {object} transport.WebRTCConnectResponse "SDP Answer negotiation success"
|
||||
// @Router /api/webrtc/connect [post]
|
||||
func (s *WebRTCService) ConnectHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed")
|
||||
return
|
||||
}
|
||||
|
||||
var req transport.WebRTCConnectRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if req.DriverID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "driver_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve device ID for this driver
|
||||
deviceID := uint8(1) // default fallback
|
||||
if driver, err := s.lobbySvc.GetDriver(req.DriverID); err == nil && driver.CarID != nil && *driver.CarID != "" {
|
||||
if car, ok := s.catalogSvc.GetCar(*driver.CarID); ok && car.DeviceID != nil {
|
||||
deviceID = uint8(*car.DeviceID)
|
||||
} else {
|
||||
s.logger.Warn("webrtc client connected but car has no physical device_id assigned in catalog. Defaulting to device 1", "driver_id", req.DriverID, "car_id", *driver.CarID)
|
||||
}
|
||||
} else {
|
||||
s.logger.Warn("webrtc client connected but no car_id assigned in lobbySvc. Defaulting to device 1", "driver_id", req.DriverID)
|
||||
}
|
||||
|
||||
// Create PeerConnection
|
||||
config := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pc, err := webrtc.NewPeerConnection(config)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to create webrtc peer connection", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare context for background video pumping
|
||||
connCtx, connCancel := context.WithCancel(context.Background())
|
||||
|
||||
session := &driverSession{
|
||||
driverID: req.DriverID,
|
||||
deviceID: deviceID,
|
||||
pc: pc,
|
||||
cancelCtx: connCancel,
|
||||
}
|
||||
|
||||
// Handle Data Channels
|
||||
pc.OnDataChannel(func(d *webrtc.DataChannel) {
|
||||
s.logger.Info("received remote data channel", "label", d.Label(), "driver_id", req.DriverID)
|
||||
s.sessionsMu.Lock()
|
||||
if d.Label() == "video" {
|
||||
session.videoDC = d
|
||||
} else if d.Label() == "data" {
|
||||
session.dataDC = d
|
||||
}
|
||||
s.sessionsMu.Unlock()
|
||||
|
||||
d.OnOpen(func() {
|
||||
s.logger.Info("data channel opened", "label", d.Label(), "driver_id", req.DriverID)
|
||||
if d.Label() == "video" {
|
||||
// Start pumping video frames from UDPVideoService to the data channel
|
||||
go s.pumpVideo(connCtx, session)
|
||||
}
|
||||
})
|
||||
|
||||
d.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
if d.Label() == "data" {
|
||||
s.handleDataChannelMessage(session, msg.Data)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
s.logger.Info("webrtc connection state changed", "state", state.String(), "driver_id", req.DriverID)
|
||||
if state == webrtc.PeerConnectionStateClosed || state == webrtc.PeerConnectionStateFailed {
|
||||
s.CloseSession(req.DriverID)
|
||||
}
|
||||
})
|
||||
|
||||
// Set the remote SessionDescription
|
||||
err = pc.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeOffer,
|
||||
SDP: req.SDP,
|
||||
})
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
connCancel()
|
||||
s.logger.Error("failed to set remote description", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Create Answer
|
||||
answer, err := pc.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
connCancel()
|
||||
s.logger.Error("failed to create answer", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Set Local Description
|
||||
err = pc.SetLocalDescription(answer)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
connCancel()
|
||||
s.logger.Error("failed to set local description", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for ICE Gathering to complete (Vanilla ICE)
|
||||
gatherComplete := webrtc.GatheringCompletePromise(pc)
|
||||
<-gatherComplete
|
||||
|
||||
// Save Session
|
||||
s.sessionsMu.Lock()
|
||||
// Clean up existing session if any
|
||||
if existing, ok := s.sessions[req.DriverID]; ok {
|
||||
existing.pc.Close()
|
||||
existing.cancelCtx()
|
||||
}
|
||||
s.sessions[req.DriverID] = session
|
||||
s.sessionsMu.Unlock()
|
||||
|
||||
localDesc := pc.LocalDescription()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(transport.WebRTCConnectResponse{
|
||||
SDP: localDesc.SDP,
|
||||
Type: "answer",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebRTCService) pumpVideo(ctx context.Context, session *driverSession) {
|
||||
s.logger.Info("starting WebRTC video pump for driver", "driver_id", session.driverID, "device_id", session.deviceID)
|
||||
|
||||
frameCh := make(chan []byte, 10)
|
||||
s.videoSvc.registerClient(session.deviceID, frameCh)
|
||||
defer func() {
|
||||
s.videoSvc.unregisterClient(session.deviceID, frameCh)
|
||||
s.logger.Info("stopping WebRTC video pump for driver", "driver_id", session.driverID)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case frame, ok := <-frameCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.sessionsMu.RLock()
|
||||
dc := session.videoDC
|
||||
s.sessionsMu.RUnlock()
|
||||
|
||||
if dc != nil && dc.ReadyState() == webrtc.DataChannelStateOpen {
|
||||
if err := dc.Send(frame); err != nil {
|
||||
s.logger.Debug("failed to send video frame over datachannel", "driver_id", session.driverID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebRTCService) handleDataChannelMessage(session *driverSession, data []byte) {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
s.logger.Warn("received invalid JSON on data channel", "driver_id", session.driverID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Steering & Throttle inputs
|
||||
in := transport.InputState{}
|
||||
hasInput := false
|
||||
if v, ok := payload["throttle"].(float64); ok {
|
||||
in.Throttle = v
|
||||
hasInput = true
|
||||
}
|
||||
if v, ok := payload["steering"].(float64); ok {
|
||||
in.Steering = v
|
||||
hasInput = true
|
||||
}
|
||||
if v, ok := payload["brake"].(float64); ok {
|
||||
in.Brake = v
|
||||
hasInput = true
|
||||
}
|
||||
if v, ok := payload["gear"].(float64); ok {
|
||||
in.Gear = int(v)
|
||||
hasInput = true
|
||||
}
|
||||
if v, ok := payload["buttons"].(float64); ok {
|
||||
in.Buttons = uint32(v)
|
||||
hasInput = true
|
||||
}
|
||||
|
||||
if hasInput {
|
||||
s.logger.Debug("received inputs on webrtc data channel", "driver_id", session.driverID, "throttle", in.Throttle, "steering", in.Steering)
|
||||
car := s.engine.GetCarByDriver(session.driverID)
|
||||
if car != nil {
|
||||
car.ApplyInput(in, 1.0/60.0)
|
||||
}
|
||||
|
||||
// Resolve destination device
|
||||
var destDeviceID *uint8
|
||||
if car != nil && car.DeviceID != nil {
|
||||
val := uint8(*car.DeviceID)
|
||||
destDeviceID = &val
|
||||
} else if session.deviceID != 0 {
|
||||
// Fallback to connection-time device
|
||||
destDeviceID = &session.deviceID
|
||||
}
|
||||
|
||||
if destDeviceID != nil {
|
||||
var packet [5]byte
|
||||
|
||||
// 1. Steer: mapping float64 [-1.0, 1.0] to [0..200] (100 is center)
|
||||
steerVal := int(math.Round((in.Steering + 1.0) * 100.0))
|
||||
if steerVal < 0 { steerVal = 0 }
|
||||
if steerVal > 200 { steerVal = 200 }
|
||||
packet[0] = uint8(steerVal)
|
||||
|
||||
// 2. Throttle: mapping float64 [0.0, 1.0] to [0..100]
|
||||
throttleVal := int(math.Round(in.Throttle * 100.0))
|
||||
if throttleVal < 0 { throttleVal = 0 }
|
||||
if throttleVal > 100 { throttleVal = 100 }
|
||||
packet[1] = uint8(throttleVal)
|
||||
|
||||
// 3. Brake: mapping float64 [0.0, 1.0] to [0..100]
|
||||
brakeVal := int(math.Round(in.Brake * 100.0))
|
||||
if brakeVal < 0 { brakeVal = 0 }
|
||||
if brakeVal > 100 { brakeVal = 100 }
|
||||
packet[2] = uint8(brakeVal)
|
||||
|
||||
// 4. Gear: map -1 to 0 (R), 0 to 1 (N), >=1 to 2 (D)
|
||||
gearVal := 1 // Neutral by default
|
||||
if in.Gear < 0 {
|
||||
gearVal = 0 // R
|
||||
} else if in.Gear > 0 {
|
||||
gearVal = 2 // D
|
||||
}
|
||||
packet[3] = uint8(gearVal)
|
||||
|
||||
// 5. Flags: bit 0 (DRS), bit 1 (Pit Limiter)
|
||||
packet[4] = uint8(in.Buttons & 0x03)
|
||||
|
||||
hexCmd := hex.EncodeToString(packet[:])
|
||||
if hexCmd != session.lastCommand {
|
||||
s.logger.Info("webrtc control state transition", "driver_id", session.driverID, "old_hex", session.lastCommand, "new_hex", hexCmd)
|
||||
session.lastCommand = hexCmd
|
||||
err := s.videoSvc.SendControlPacket(*destDeviceID, packet)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to forward control packet to ESP32", "device_id", *destDeviceID, "packet", packet, "err", err)
|
||||
} else {
|
||||
s.logger.Info("forwarded control packet to ESP32 via UDP", "device_id", *destDeviceID, "packet", packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Echo back message / send ACK
|
||||
s.sessionsMu.RLock()
|
||||
dc := session.dataDC
|
||||
s.sessionsMu.RUnlock()
|
||||
if dc != nil && dc.ReadyState() == webrtc.DataChannelStateOpen {
|
||||
ack, _ := json.Marshal(map[string]any{
|
||||
"type": "ack",
|
||||
"applied": true,
|
||||
"timestamp": time.Now().UnixMilli(),
|
||||
})
|
||||
_ = dc.SendText(string(ack))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebRTCService) broadcastTelemetry() {
|
||||
s.sessionsMu.RLock()
|
||||
defer s.sessionsMu.RUnlock()
|
||||
|
||||
for _, session := range s.sessions {
|
||||
dc := session.dataDC
|
||||
if dc != nil && dc.ReadyState() == webrtc.DataChannelStateOpen {
|
||||
// Pull some stats/telemetry
|
||||
car := s.engine.GetCarByDriver(session.driverID)
|
||||
var speed float64
|
||||
var x, y float64
|
||||
var carID string
|
||||
if car != nil {
|
||||
speed = car.Speed
|
||||
x = car.X
|
||||
y = car.Y
|
||||
carID = car.ID
|
||||
}
|
||||
|
||||
telemetry := map[string]any{
|
||||
"type": "telemetry",
|
||||
"car_id": carID,
|
||||
"driver_id": session.driverID,
|
||||
"speed": speed,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"timestamp": time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(telemetry)
|
||||
_ = dc.SendText(string(payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebRTCService) CloseSession(driverID string) {
|
||||
s.sessionsMu.Lock()
|
||||
defer s.sessionsMu.Unlock()
|
||||
|
||||
if session, ok := s.sessions[driverID]; ok {
|
||||
s.logger.Info("closing WebRTC session for driver", "driver_id", driverID)
|
||||
session.pc.Close()
|
||||
session.cancelCtx()
|
||||
delete(s.sessions, driverID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebRTCService) CloseAll() {
|
||||
s.sessionsMu.Lock()
|
||||
defer s.sessionsMu.Unlock()
|
||||
|
||||
for driverID, session := range s.sessions {
|
||||
s.logger.Info("closing WebRTC session (shutdown)", "driver_id", driverID)
|
||||
session.pc.Close()
|
||||
session.cancelCtx()
|
||||
}
|
||||
s.sessions = make(map[string]*driverSession)
|
||||
}
|
||||
+573
-6
@@ -948,7 +948,7 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/drivers/{id}/device": {
|
||||
"/api/drivers/{id}/car": {
|
||||
"put": {
|
||||
"description": "Dispatches on HTTP method. Path ` + "`" + `/api/drivers/by-nick/{nick}` + "`" + ` is a convenience that returns the driver by 3-letter nickname.",
|
||||
"produces": [
|
||||
@@ -1012,6 +1012,120 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/leaderboard": {
|
||||
"get": {
|
||||
"description": "Returns paginated leaderboard list. Supports overall leaderboard or track-specific leaderboard for the current year.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"leaderboard"
|
||||
],
|
||||
"summary": "Leaderboard",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Track ID. If empty, returns overall leaderboard.",
|
||||
"name": "track_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Year. Defaults to current year.",
|
||||
"name": "year",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Limit (1..200, default 50)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Offset (default 0)",
|
||||
"name": "offset",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Current driver ID. Defaults to driver-seed-001 (mock Alice).",
|
||||
"name": "X-Driver-Id",
|
||||
"in": "header"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Leaderboard page and current driver position",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.LeaderboardResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/leaderboard/tracks": {
|
||||
"get": {
|
||||
"description": "Returns the list of scheduled tracks with their status (finished/active/planned), top 3 drivers, and current driver position, sorted by calendar start date.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"leaderboard"
|
||||
],
|
||||
"summary": "Track Calendar Leaderboards",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Year. Defaults to current year.",
|
||||
"name": "year",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Current driver ID. Defaults to driver-seed-001 (mock Alice).",
|
||||
"name": "X-Driver-Id",
|
||||
"in": "header"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Calendar leaderboards list",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.TrackCalendarLeaderboardResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/races": {
|
||||
"get": {
|
||||
"description": "Returns one keyset page of races. Live races (status lobby|racing) come from in-memory lobby.Service; finished races come from Postgres. The two sources are merged and ordered by (started_ms DESC, id ASC). The cursor is opaque: pass back the next_cursor verbatim from a previous page.\n## Filters\n- ` + "`" + `status` + "`" + ` (comma-separated, e.g. \"racing,finished\"). Recognised values: all, lobby, racing, finished.\n- ` + "`" + `track_id` + "`" + ` (exact match against a physical track id; custom lobbies are not supported in this build).\n- ` + "`" + `limit` + "`" + ` (1..200, default 50)",
|
||||
@@ -1078,6 +1192,40 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/races/last": {
|
||||
"get": {
|
||||
"description": "Returns the details and podium/results of the most recently finished race.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"races"
|
||||
],
|
||||
"summary": "Get the results of the last finished race",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Last race results",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.LobbyRace"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No finished races found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/races/plans": {
|
||||
"get": {
|
||||
"description": "Returns race plans in ascending start_at order. Pass back the ` + "`" + `next_cursor` + "`" + ` to get the next page.",
|
||||
@@ -1420,6 +1568,56 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/races/{id}": {
|
||||
"get": {
|
||||
"description": "Returns details of a single race by its ID. Works for both live and finished races.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"races"
|
||||
],
|
||||
"summary": "Get results of a specific race by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Race ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Race details",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.LobbyRace"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Missing race id",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Race not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/tracks": {
|
||||
"get": {
|
||||
"description": "Returns all tracks visible to the caller. Optional query filters: ` + "`" + `scope=system|public|private` + "`" + `, ` + "`" + `tag=\u003cstring\u003e` + "`" + `.",
|
||||
@@ -1520,6 +1718,123 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/tracks/calendar": {
|
||||
"get": {
|
||||
"description": "Returns the full calendar of track active periods.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"tracks"
|
||||
],
|
||||
"summary": "Get Track Calendar",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of scheduled active periods",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.TrackCalendarResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Schedules an active period for a physical track.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"tracks"
|
||||
],
|
||||
"summary": "Schedule Track Active Period",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Calendar entry details",
|
||||
"name": "payload",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.TrackCalendarCreateRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created calendar entry",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.TrackCalendarEntry"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Track not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/tracks/calendar/{id}": {
|
||||
"delete": {
|
||||
"description": "Deletes a scheduled active period by ID.",
|
||||
"tags": [
|
||||
"tracks"
|
||||
],
|
||||
"summary": "Delete Scheduled Period",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Calendar Entry ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/tracks/{id}": {
|
||||
"get": {
|
||||
"description": "Dispatches on HTTP method. The id is taken from the URL path (` + "`" + `/api/tracks/{id}` + "`" + `). System tracks are immutable and cannot be updated or deleted.",
|
||||
@@ -1772,7 +2087,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/video/control": {
|
||||
"post": {
|
||||
"description": "Sends a text command (e.g. start, stop, left, right) to the ESP32 command port (UDP 8888).",
|
||||
"description": "Sends a text command or a JSON control packet to the ESP32 command port (UDP 8888).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1789,10 +2104,20 @@ const docTemplate = `{
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Command text",
|
||||
"description": "Command text (legacy)",
|
||||
"name": "cmd",
|
||||
"in": "query",
|
||||
"required": true
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"description": "Control packet JSON (steer, throttle, brake, gear, flags)",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -1804,7 +2129,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Missing cmd parameter",
|
||||
"description": "Missing parameter or invalid body",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -1846,6 +2171,40 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/webrtc/connect": {
|
||||
"post": {
|
||||
"description": "Accepts SDP Offer, negotiates peer connection, and returns SDP Answer. Routes video and control data via DataChannels.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"webrtc"
|
||||
],
|
||||
"summary": "Establish WebRTC connection for driver-car 1-to-1 proxying",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "SDP Offer details",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.WebRTCConnectRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "SDP Answer negotiation success",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.WebRTCConnectResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"description": "Returns 200 OK with current engine phase, WS connection count and total snapshot drops. Intended for load-balancer health checks.",
|
||||
@@ -1967,6 +2326,9 @@ const docTemplate = `{
|
||||
"color_hex": {
|
||||
"type": "string"
|
||||
},
|
||||
"device_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"drive": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2034,6 +2396,9 @@ const docTemplate = `{
|
||||
"created_ms": {
|
||||
"type": "integer"
|
||||
},
|
||||
"device_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"drive": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2263,6 +2628,10 @@ const docTemplate = `{
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "Alice"
|
||||
},
|
||||
"nickname": {
|
||||
"type": "string",
|
||||
"example": "ALI"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2281,6 +2650,78 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.LeaderboardEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"avatar_url": {
|
||||
"type": "string",
|
||||
"example": "https://cdn.example.com/u/ace.png"
|
||||
},
|
||||
"best_pos": {
|
||||
"description": "Only populated for track-specific leaderboard",
|
||||
"type": "integer",
|
||||
"example": 1
|
||||
},
|
||||
"best_time_ms": {
|
||||
"type": "integer",
|
||||
"example": 15000
|
||||
},
|
||||
"clan_id": {
|
||||
"type": "string",
|
||||
"example": "clan-seed-001"
|
||||
},
|
||||
"clan_tag": {
|
||||
"type": "string",
|
||||
"example": "ACE"
|
||||
},
|
||||
"driver_id": {
|
||||
"type": "string",
|
||||
"example": "driver-seed-001"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "Alice"
|
||||
},
|
||||
"nickname": {
|
||||
"type": "string",
|
||||
"example": "ACE"
|
||||
},
|
||||
"points": {
|
||||
"type": "integer",
|
||||
"example": 25
|
||||
},
|
||||
"rank": {
|
||||
"type": "integer",
|
||||
"example": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.LeaderboardResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current_driver": {
|
||||
"$ref": "#/definitions/transport.LeaderboardEntry"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/transport.LeaderboardEntry"
|
||||
}
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"example": 50
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"example": 0
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"example": 8
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.LobbyRace": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2741,6 +3182,100 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.TrackCalendarCreateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"end_at": {
|
||||
"type": "string",
|
||||
"example": "2026-07-30T00:00:00Z"
|
||||
},
|
||||
"start_at": {
|
||||
"type": "string",
|
||||
"example": "2026-07-15T00:00:00Z"
|
||||
},
|
||||
"track_id": {
|
||||
"type": "string",
|
||||
"example": "monaco"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.TrackCalendarEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"end_at": {
|
||||
"type": "string",
|
||||
"example": "2026-07-30T00:00:00Z"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"example": 1
|
||||
},
|
||||
"start_at": {
|
||||
"type": "string",
|
||||
"example": "2026-07-15T00:00:00Z"
|
||||
},
|
||||
"track_id": {
|
||||
"type": "string",
|
||||
"example": "monaco"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.TrackCalendarLeaderboard": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current_driver": {
|
||||
"$ref": "#/definitions/transport.LeaderboardEntry"
|
||||
},
|
||||
"end_at": {
|
||||
"type": "string",
|
||||
"example": "2026-07-30T00:00:00Z"
|
||||
},
|
||||
"podium": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/transport.LeaderboardEntry"
|
||||
}
|
||||
},
|
||||
"start_at": {
|
||||
"type": "string",
|
||||
"example": "2026-07-15T00:00:00Z"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "active"
|
||||
},
|
||||
"track_id": {
|
||||
"type": "string",
|
||||
"example": "monaco"
|
||||
},
|
||||
"track_name": {
|
||||
"type": "string",
|
||||
"example": "Monaco"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.TrackCalendarLeaderboardResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/transport.TrackCalendarLeaderboard"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.TrackCalendarResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/transport.TrackCalendarEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.TrackCreateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2851,6 +3386,10 @@ const docTemplate = `{
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_active": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"lane_width_m": {
|
||||
"type": "number"
|
||||
},
|
||||
@@ -2891,6 +3430,34 @@ const docTemplate = `{
|
||||
"example": "0.1.0-poc"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.WebRTCConnectRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"driver_id": {
|
||||
"type": "string",
|
||||
"example": "driver-alice"
|
||||
},
|
||||
"sdp": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "offer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.WebRTCConnectResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sdp": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "answer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
+573
-6
@@ -946,7 +946,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/drivers/{id}/device": {
|
||||
"/api/drivers/{id}/car": {
|
||||
"put": {
|
||||
"description": "Dispatches on HTTP method. Path `/api/drivers/by-nick/{nick}` is a convenience that returns the driver by 3-letter nickname.",
|
||||
"produces": [
|
||||
@@ -1010,6 +1010,120 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/leaderboard": {
|
||||
"get": {
|
||||
"description": "Returns paginated leaderboard list. Supports overall leaderboard or track-specific leaderboard for the current year.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"leaderboard"
|
||||
],
|
||||
"summary": "Leaderboard",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Track ID. If empty, returns overall leaderboard.",
|
||||
"name": "track_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Year. Defaults to current year.",
|
||||
"name": "year",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Limit (1..200, default 50)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Offset (default 0)",
|
||||
"name": "offset",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Current driver ID. Defaults to driver-seed-001 (mock Alice).",
|
||||
"name": "X-Driver-Id",
|
||||
"in": "header"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Leaderboard page and current driver position",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.LeaderboardResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/leaderboard/tracks": {
|
||||
"get": {
|
||||
"description": "Returns the list of scheduled tracks with their status (finished/active/planned), top 3 drivers, and current driver position, sorted by calendar start date.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"leaderboard"
|
||||
],
|
||||
"summary": "Track Calendar Leaderboards",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Year. Defaults to current year.",
|
||||
"name": "year",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Current driver ID. Defaults to driver-seed-001 (mock Alice).",
|
||||
"name": "X-Driver-Id",
|
||||
"in": "header"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Calendar leaderboards list",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.TrackCalendarLeaderboardResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/races": {
|
||||
"get": {
|
||||
"description": "Returns one keyset page of races. Live races (status lobby|racing) come from in-memory lobby.Service; finished races come from Postgres. The two sources are merged and ordered by (started_ms DESC, id ASC). The cursor is opaque: pass back the next_cursor verbatim from a previous page.\n## Filters\n- `status` (comma-separated, e.g. \"racing,finished\"). Recognised values: all, lobby, racing, finished.\n- `track_id` (exact match against a physical track id; custom lobbies are not supported in this build).\n- `limit` (1..200, default 50)",
|
||||
@@ -1076,6 +1190,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/races/last": {
|
||||
"get": {
|
||||
"description": "Returns the details and podium/results of the most recently finished race.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"races"
|
||||
],
|
||||
"summary": "Get the results of the last finished race",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Last race results",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.LobbyRace"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No finished races found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/races/plans": {
|
||||
"get": {
|
||||
"description": "Returns race plans in ascending start_at order. Pass back the `next_cursor` to get the next page.",
|
||||
@@ -1418,6 +1566,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/races/{id}": {
|
||||
"get": {
|
||||
"description": "Returns details of a single race by its ID. Works for both live and finished races.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"races"
|
||||
],
|
||||
"summary": "Get results of a specific race by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Race ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Race details",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.LobbyRace"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Missing race id",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Race not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/tracks": {
|
||||
"get": {
|
||||
"description": "Returns all tracks visible to the caller. Optional query filters: `scope=system|public|private`, `tag=\u003cstring\u003e`.",
|
||||
@@ -1518,6 +1716,123 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/tracks/calendar": {
|
||||
"get": {
|
||||
"description": "Returns the full calendar of track active periods.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"tracks"
|
||||
],
|
||||
"summary": "Get Track Calendar",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of scheduled active periods",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.TrackCalendarResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Schedules an active period for a physical track.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"tracks"
|
||||
],
|
||||
"summary": "Schedule Track Active Period",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Calendar entry details",
|
||||
"name": "payload",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.TrackCalendarCreateRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created calendar entry",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.TrackCalendarEntry"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Track not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/tracks/calendar/{id}": {
|
||||
"delete": {
|
||||
"description": "Deletes a scheduled active period by ID.",
|
||||
"tags": [
|
||||
"tracks"
|
||||
],
|
||||
"summary": "Delete Scheduled Period",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Calendar Entry ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/tracks/{id}": {
|
||||
"get": {
|
||||
"description": "Dispatches on HTTP method. The id is taken from the URL path (`/api/tracks/{id}`). System tracks are immutable and cannot be updated or deleted.",
|
||||
@@ -1770,7 +2085,7 @@
|
||||
},
|
||||
"/api/video/control": {
|
||||
"post": {
|
||||
"description": "Sends a text command (e.g. start, stop, left, right) to the ESP32 command port (UDP 8888).",
|
||||
"description": "Sends a text command or a JSON control packet to the ESP32 command port (UDP 8888).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1787,10 +2102,20 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Command text",
|
||||
"description": "Command text (legacy)",
|
||||
"name": "cmd",
|
||||
"in": "query",
|
||||
"required": true
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"description": "Control packet JSON (steer, throttle, brake, gear, flags)",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -1802,7 +2127,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Missing cmd parameter",
|
||||
"description": "Missing parameter or invalid body",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -1844,6 +2169,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/webrtc/connect": {
|
||||
"post": {
|
||||
"description": "Accepts SDP Offer, negotiates peer connection, and returns SDP Answer. Routes video and control data via DataChannels.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"webrtc"
|
||||
],
|
||||
"summary": "Establish WebRTC connection for driver-car 1-to-1 proxying",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "SDP Offer details",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.WebRTCConnectRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "SDP Answer negotiation success",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/transport.WebRTCConnectResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"description": "Returns 200 OK with current engine phase, WS connection count and total snapshot drops. Intended for load-balancer health checks.",
|
||||
@@ -1965,6 +2324,9 @@
|
||||
"color_hex": {
|
||||
"type": "string"
|
||||
},
|
||||
"device_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"drive": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2032,6 +2394,9 @@
|
||||
"created_ms": {
|
||||
"type": "integer"
|
||||
},
|
||||
"device_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"drive": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2261,6 +2626,10 @@
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "Alice"
|
||||
},
|
||||
"nickname": {
|
||||
"type": "string",
|
||||
"example": "ALI"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2279,6 +2648,78 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.LeaderboardEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"avatar_url": {
|
||||
"type": "string",
|
||||
"example": "https://cdn.example.com/u/ace.png"
|
||||
},
|
||||
"best_pos": {
|
||||
"description": "Only populated for track-specific leaderboard",
|
||||
"type": "integer",
|
||||
"example": 1
|
||||
},
|
||||
"best_time_ms": {
|
||||
"type": "integer",
|
||||
"example": 15000
|
||||
},
|
||||
"clan_id": {
|
||||
"type": "string",
|
||||
"example": "clan-seed-001"
|
||||
},
|
||||
"clan_tag": {
|
||||
"type": "string",
|
||||
"example": "ACE"
|
||||
},
|
||||
"driver_id": {
|
||||
"type": "string",
|
||||
"example": "driver-seed-001"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "Alice"
|
||||
},
|
||||
"nickname": {
|
||||
"type": "string",
|
||||
"example": "ACE"
|
||||
},
|
||||
"points": {
|
||||
"type": "integer",
|
||||
"example": 25
|
||||
},
|
||||
"rank": {
|
||||
"type": "integer",
|
||||
"example": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.LeaderboardResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current_driver": {
|
||||
"$ref": "#/definitions/transport.LeaderboardEntry"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/transport.LeaderboardEntry"
|
||||
}
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"example": 50
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"example": 0
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"example": 8
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.LobbyRace": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2739,6 +3180,100 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.TrackCalendarCreateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"end_at": {
|
||||
"type": "string",
|
||||
"example": "2026-07-30T00:00:00Z"
|
||||
},
|
||||
"start_at": {
|
||||
"type": "string",
|
||||
"example": "2026-07-15T00:00:00Z"
|
||||
},
|
||||
"track_id": {
|
||||
"type": "string",
|
||||
"example": "monaco"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.TrackCalendarEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"end_at": {
|
||||
"type": "string",
|
||||
"example": "2026-07-30T00:00:00Z"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"example": 1
|
||||
},
|
||||
"start_at": {
|
||||
"type": "string",
|
||||
"example": "2026-07-15T00:00:00Z"
|
||||
},
|
||||
"track_id": {
|
||||
"type": "string",
|
||||
"example": "monaco"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.TrackCalendarLeaderboard": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current_driver": {
|
||||
"$ref": "#/definitions/transport.LeaderboardEntry"
|
||||
},
|
||||
"end_at": {
|
||||
"type": "string",
|
||||
"example": "2026-07-30T00:00:00Z"
|
||||
},
|
||||
"podium": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/transport.LeaderboardEntry"
|
||||
}
|
||||
},
|
||||
"start_at": {
|
||||
"type": "string",
|
||||
"example": "2026-07-15T00:00:00Z"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "active"
|
||||
},
|
||||
"track_id": {
|
||||
"type": "string",
|
||||
"example": "monaco"
|
||||
},
|
||||
"track_name": {
|
||||
"type": "string",
|
||||
"example": "Monaco"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.TrackCalendarLeaderboardResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/transport.TrackCalendarLeaderboard"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.TrackCalendarResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/transport.TrackCalendarEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.TrackCreateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2849,6 +3384,10 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_active": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"lane_width_m": {
|
||||
"type": "number"
|
||||
},
|
||||
@@ -2889,6 +3428,34 @@
|
||||
"example": "0.1.0-poc"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.WebRTCConnectRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"driver_id": {
|
||||
"type": "string",
|
||||
"example": "driver-alice"
|
||||
},
|
||||
"sdp": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "offer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport.WebRTCConnectResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sdp": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "answer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+397
-6
@@ -39,6 +39,8 @@ definitions:
|
||||
$ref: '#/definitions/transport.ChassisWire'
|
||||
color_hex:
|
||||
type: string
|
||||
device_id:
|
||||
type: integer
|
||||
drive:
|
||||
type: string
|
||||
height_mm:
|
||||
@@ -83,6 +85,8 @@ definitions:
|
||||
type: string
|
||||
created_ms:
|
||||
type: integer
|
||||
device_id:
|
||||
type: integer
|
||||
drive:
|
||||
type: string
|
||||
height_mm:
|
||||
@@ -241,6 +245,9 @@ definitions:
|
||||
name:
|
||||
example: Alice
|
||||
type: string
|
||||
nickname:
|
||||
example: ALI
|
||||
type: string
|
||||
type: object
|
||||
transport.Envelope:
|
||||
properties:
|
||||
@@ -252,6 +259,58 @@ definitions:
|
||||
type:
|
||||
$ref: '#/definitions/transport.MessageType'
|
||||
type: object
|
||||
transport.LeaderboardEntry:
|
||||
properties:
|
||||
avatar_url:
|
||||
example: https://cdn.example.com/u/ace.png
|
||||
type: string
|
||||
best_pos:
|
||||
description: Only populated for track-specific leaderboard
|
||||
example: 1
|
||||
type: integer
|
||||
best_time_ms:
|
||||
example: 15000
|
||||
type: integer
|
||||
clan_id:
|
||||
example: clan-seed-001
|
||||
type: string
|
||||
clan_tag:
|
||||
example: ACE
|
||||
type: string
|
||||
driver_id:
|
||||
example: driver-seed-001
|
||||
type: string
|
||||
name:
|
||||
example: Alice
|
||||
type: string
|
||||
nickname:
|
||||
example: ACE
|
||||
type: string
|
||||
points:
|
||||
example: 25
|
||||
type: integer
|
||||
rank:
|
||||
example: 1
|
||||
type: integer
|
||||
type: object
|
||||
transport.LeaderboardResponse:
|
||||
properties:
|
||||
current_driver:
|
||||
$ref: '#/definitions/transport.LeaderboardEntry'
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/transport.LeaderboardEntry'
|
||||
type: array
|
||||
limit:
|
||||
example: 50
|
||||
type: integer
|
||||
offset:
|
||||
example: 0
|
||||
type: integer
|
||||
total:
|
||||
example: 8
|
||||
type: integer
|
||||
type: object
|
||||
transport.LobbyRace:
|
||||
properties:
|
||||
created_ms:
|
||||
@@ -592,6 +651,71 @@ definitions:
|
||||
width_m:
|
||||
type: number
|
||||
type: object
|
||||
transport.TrackCalendarCreateRequest:
|
||||
properties:
|
||||
end_at:
|
||||
example: "2026-07-30T00:00:00Z"
|
||||
type: string
|
||||
start_at:
|
||||
example: "2026-07-15T00:00:00Z"
|
||||
type: string
|
||||
track_id:
|
||||
example: monaco
|
||||
type: string
|
||||
type: object
|
||||
transport.TrackCalendarEntry:
|
||||
properties:
|
||||
end_at:
|
||||
example: "2026-07-30T00:00:00Z"
|
||||
type: string
|
||||
id:
|
||||
example: 1
|
||||
type: integer
|
||||
start_at:
|
||||
example: "2026-07-15T00:00:00Z"
|
||||
type: string
|
||||
track_id:
|
||||
example: monaco
|
||||
type: string
|
||||
type: object
|
||||
transport.TrackCalendarLeaderboard:
|
||||
properties:
|
||||
current_driver:
|
||||
$ref: '#/definitions/transport.LeaderboardEntry'
|
||||
end_at:
|
||||
example: "2026-07-30T00:00:00Z"
|
||||
type: string
|
||||
podium:
|
||||
items:
|
||||
$ref: '#/definitions/transport.LeaderboardEntry'
|
||||
type: array
|
||||
start_at:
|
||||
example: "2026-07-15T00:00:00Z"
|
||||
type: string
|
||||
status:
|
||||
example: active
|
||||
type: string
|
||||
track_id:
|
||||
example: monaco
|
||||
type: string
|
||||
track_name:
|
||||
example: Monaco
|
||||
type: string
|
||||
type: object
|
||||
transport.TrackCalendarLeaderboardResponse:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/transport.TrackCalendarLeaderboard'
|
||||
type: array
|
||||
type: object
|
||||
transport.TrackCalendarResponse:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/transport.TrackCalendarEntry'
|
||||
type: array
|
||||
type: object
|
||||
transport.TrackCreateRequest:
|
||||
properties:
|
||||
track:
|
||||
@@ -664,6 +788,9 @@ definitions:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
is_active:
|
||||
example: true
|
||||
type: boolean
|
||||
lane_width_m:
|
||||
type: number
|
||||
length_m:
|
||||
@@ -691,6 +818,25 @@ definitions:
|
||||
example: 0.1.0-poc
|
||||
type: string
|
||||
type: object
|
||||
transport.WebRTCConnectRequest:
|
||||
properties:
|
||||
driver_id:
|
||||
example: driver-alice
|
||||
type: string
|
||||
sdp:
|
||||
type: string
|
||||
type:
|
||||
example: offer
|
||||
type: string
|
||||
type: object
|
||||
transport.WebRTCConnectResponse:
|
||||
properties:
|
||||
sdp:
|
||||
type: string
|
||||
type:
|
||||
example: answer
|
||||
type: string
|
||||
type: object
|
||||
host: localhost:8080
|
||||
info:
|
||||
contact:
|
||||
@@ -1345,7 +1491,7 @@ paths:
|
||||
summary: Get / update / delete a driver by id
|
||||
tags:
|
||||
- drivers
|
||||
/api/drivers/{id}/device:
|
||||
/api/drivers/{id}/car:
|
||||
put:
|
||||
description: Dispatches on HTTP method. Path `/api/drivers/by-nick/{nick}` is
|
||||
a convenience that returns the driver by 3-letter nickname.
|
||||
@@ -1389,6 +1535,84 @@ paths:
|
||||
summary: Get / update / delete a driver by id
|
||||
tags:
|
||||
- drivers
|
||||
/api/leaderboard:
|
||||
get:
|
||||
description: Returns paginated leaderboard list. Supports overall leaderboard
|
||||
or track-specific leaderboard for the current year.
|
||||
parameters:
|
||||
- description: Track ID. If empty, returns overall leaderboard.
|
||||
in: query
|
||||
name: track_id
|
||||
type: string
|
||||
- description: Year. Defaults to current year.
|
||||
in: query
|
||||
name: year
|
||||
type: integer
|
||||
- description: Limit (1..200, default 50)
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
- description: Offset (default 0)
|
||||
in: query
|
||||
name: offset
|
||||
type: integer
|
||||
- description: Current driver ID. Defaults to driver-seed-001 (mock Alice).
|
||||
in: header
|
||||
name: X-Driver-Id
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Leaderboard page and current driver position
|
||||
schema:
|
||||
$ref: '#/definitions/transport.LeaderboardResponse'
|
||||
"400":
|
||||
description: Invalid input
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
summary: Leaderboard
|
||||
tags:
|
||||
- leaderboard
|
||||
/api/leaderboard/tracks:
|
||||
get:
|
||||
description: Returns the list of scheduled tracks with their status (finished/active/planned),
|
||||
top 3 drivers, and current driver position, sorted by calendar start date.
|
||||
parameters:
|
||||
- description: Year. Defaults to current year.
|
||||
in: query
|
||||
name: year
|
||||
type: integer
|
||||
- description: Current driver ID. Defaults to driver-seed-001 (mock Alice).
|
||||
in: header
|
||||
name: X-Driver-Id
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Calendar leaderboards list
|
||||
schema:
|
||||
$ref: '#/definitions/transport.TrackCalendarLeaderboardResponse'
|
||||
"400":
|
||||
description: Invalid input
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
summary: Track Calendar Leaderboards
|
||||
tags:
|
||||
- leaderboard
|
||||
/api/races:
|
||||
get:
|
||||
description: |-
|
||||
@@ -1439,6 +1663,65 @@ paths:
|
||||
summary: List races (keyset paginated)
|
||||
tags:
|
||||
- races
|
||||
/api/races/{id}:
|
||||
get:
|
||||
description: Returns details of a single race by its ID. Works for both live
|
||||
and finished races.
|
||||
parameters:
|
||||
- description: Race ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Race details
|
||||
schema:
|
||||
$ref: '#/definitions/transport.LobbyRace'
|
||||
"400":
|
||||
description: Missing race id
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"404":
|
||||
description: Race not found
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
summary: Get results of a specific race by ID
|
||||
tags:
|
||||
- races
|
||||
/api/races/last:
|
||||
get:
|
||||
description: Returns the details and podium/results of the most recently finished
|
||||
race.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Last race results
|
||||
schema:
|
||||
$ref: '#/definitions/transport.LobbyRace'
|
||||
"404":
|
||||
description: No finished races found
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
summary: Get the results of the last finished race
|
||||
tags:
|
||||
- races
|
||||
/api/races/plans:
|
||||
get:
|
||||
description: Returns race plans in ascending start_at order. Pass back the `next_cursor`
|
||||
@@ -1908,6 +2191,85 @@ paths:
|
||||
summary: Get / update / delete a track by id
|
||||
tags:
|
||||
- tracks
|
||||
/api/tracks/calendar:
|
||||
get:
|
||||
description: Returns the full calendar of track active periods.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: List of scheduled active periods
|
||||
schema:
|
||||
$ref: '#/definitions/transport.TrackCalendarResponse'
|
||||
"500":
|
||||
description: Internal error
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
summary: Get Track Calendar
|
||||
tags:
|
||||
- tracks
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Schedules an active period for a physical track.
|
||||
parameters:
|
||||
- description: Calendar entry details
|
||||
in: body
|
||||
name: payload
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/transport.TrackCalendarCreateRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Created calendar entry
|
||||
schema:
|
||||
$ref: '#/definitions/transport.TrackCalendarEntry'
|
||||
"400":
|
||||
description: Invalid input
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"404":
|
||||
description: Track not found
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"500":
|
||||
description: Internal error
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
summary: Schedule Track Active Period
|
||||
tags:
|
||||
- tracks
|
||||
/api/tracks/calendar/{id}:
|
||||
delete:
|
||||
description: Deletes a scheduled active period by ID.
|
||||
parameters:
|
||||
- description: Calendar Entry ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"404":
|
||||
description: Not found
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"500":
|
||||
description: Internal error
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
summary: Delete Scheduled Period
|
||||
tags:
|
||||
- tracks
|
||||
/api/version:
|
||||
get:
|
||||
description: Returns the current server version.
|
||||
@@ -1923,18 +2285,24 @@ paths:
|
||||
- system
|
||||
/api/video/control:
|
||||
post:
|
||||
description: Sends a text command (e.g. start, stop, left, right) to the ESP32
|
||||
command port (UDP 8888).
|
||||
description: Sends a text command or a JSON control packet to the ESP32 command
|
||||
port (UDP 8888).
|
||||
parameters:
|
||||
- description: Device ID (0..127)
|
||||
in: query
|
||||
name: id
|
||||
type: integer
|
||||
- description: Command text
|
||||
- description: Command text (legacy)
|
||||
in: query
|
||||
name: cmd
|
||||
required: true
|
||||
type: string
|
||||
- description: Control packet JSON (steer, throttle, brake, gear, flags)
|
||||
in: body
|
||||
name: body
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: integer
|
||||
type: object
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -1944,7 +2312,7 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"400":
|
||||
description: Missing cmd parameter
|
||||
description: Missing parameter or invalid body
|
||||
schema:
|
||||
type: string
|
||||
"500":
|
||||
@@ -1973,6 +2341,29 @@ paths:
|
||||
summary: Get MJPEG video stream from ESP32
|
||||
tags:
|
||||
- video
|
||||
/api/webrtc/connect:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Accepts SDP Offer, negotiates peer connection, and returns SDP
|
||||
Answer. Routes video and control data via DataChannels.
|
||||
parameters:
|
||||
- description: SDP Offer details
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/transport.WebRTCConnectRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: SDP Answer negotiation success
|
||||
schema:
|
||||
$ref: '#/definitions/transport.WebRTCConnectResponse'
|
||||
summary: Establish WebRTC connection for driver-car 1-to-1 proxying
|
||||
tags:
|
||||
- webrtc
|
||||
/health:
|
||||
get:
|
||||
description: Returns 200 OK with current engine phase, WS connection count and
|
||||
|
||||
+25
-4
@@ -15,17 +15,38 @@ require (
|
||||
github.com/go-openapi/jsonreference v0.20.0 // indirect
|
||||
github.com/go-openapi/spec v0.20.6 // indirect
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/pion/datachannel v1.6.2 // indirect
|
||||
github.com/pion/dtls/v3 v3.1.4 // indirect
|
||||
github.com/pion/ice/v4 v4.2.7 // indirect
|
||||
github.com/pion/interceptor v0.1.45 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.16 // indirect
|
||||
github.com/pion/rtp v1.10.2 // indirect
|
||||
github.com/pion/sctp v1.10.3 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.19 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.12 // indirect
|
||||
github.com/pion/stun/v3 v3.1.6 // indirect
|
||||
github.com/pion/transport/v4 v4.0.2 // indirect
|
||||
github.com/pion/turn/v5 v5.0.10 // indirect
|
||||
github.com/pion/webrtc/v4 v4.2.16 // 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
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
@@ -14,6 +14,8 @@ github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -39,6 +41,38 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN
|
||||
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/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc=
|
||||
github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E=
|
||||
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
|
||||
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
|
||||
github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao=
|
||||
github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY=
|
||||
github.com/pion/interceptor v0.1.45 h1:6PUo/5829bIfRFIPPJQzuDn8EjxRTSB/CSD7QVCOaqo=
|
||||
github.com/pion/interceptor v0.1.45/go.mod h1:gNDYM/uFKcLe/B3gS2/7+aw6z+RDiMy2qKTnF1LO31w=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
|
||||
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
|
||||
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
|
||||
github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
|
||||
github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
|
||||
github.com/pion/sctp v1.10.3 h1:1gBtLMA9lmwNuJkZSZJCdD5/Hz4yJs+7dAqi6ZY97QI=
|
||||
github.com/pion/sctp v1.10.3/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0=
|
||||
github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ=
|
||||
github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU=
|
||||
github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc=
|
||||
github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
|
||||
github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
|
||||
github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
|
||||
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
|
||||
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
||||
github.com/pion/turn/v5 v5.0.10 h1:mOMZjudflXpte5OsCnXztpUKwNXcpXIAzMBnq9TXOSQ=
|
||||
github.com/pion/turn/v5 v5.0.10/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E=
|
||||
github.com/pion/webrtc/v4 v4.2.16 h1:oK1GAg0TWJtZWYB8J/BgTgGWPoV2148gQWocH12vr3Q=
|
||||
github.com/pion/webrtc/v4 v4.2.16/go.mod h1:y4HjLAkX90LH+C/qPqGOUgz8RA8CbDj3Iar3d+2hdKQ=
|
||||
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=
|
||||
@@ -53,9 +87,13 @@ github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64
|
||||
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/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
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/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
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=
|
||||
@@ -63,16 +101,22 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||
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/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
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/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.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/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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=
|
||||
@@ -82,11 +126,17 @@ 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/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
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/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
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=
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Package auth handles Supabase JWT validation and HTTP middleware.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
driverIDKey contextKey = iota
|
||||
emailKey
|
||||
)
|
||||
|
||||
// Claims represents the parsed Supabase JWT claims we care about.
|
||||
type Claims struct {
|
||||
DriverID string
|
||||
Email string
|
||||
}
|
||||
|
||||
// ValidateJWT parses and validates a Supabase HMAC-SHA256 JWT using the secret.
|
||||
func ValidateJWT(tokenStr string, secret string) (Claims, error) {
|
||||
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||
sub, _ := claims["sub"].(string)
|
||||
email, _ := claims["email"].(string)
|
||||
if sub == "" {
|
||||
return Claims{}, errors.New("missing sub claim")
|
||||
}
|
||||
return Claims{
|
||||
DriverID: sub,
|
||||
Email: email,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return Claims{}, errors.New("invalid token")
|
||||
}
|
||||
|
||||
// Middleware returns an HTTP middleware that extracts and validates the Bearer token.
|
||||
// When devMode is enabled, authentication is bypassed entirely.
|
||||
func Middleware(secret string, devMode bool) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if devMode {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthorized","message":"missing authorization header"}`))
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthorized","message":"invalid authorization format"}`))
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := ValidateJWT(parts[1], secret)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`{"error":"unauthorized","message":%q}`, err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), driverIDKey, claims.DriverID)
|
||||
ctx = context.WithValue(ctx, emailKey, claims.Email)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DriverIDFromContext retrieves the driver ID from the context.
|
||||
func DriverIDFromContext(ctx context.Context) (string, bool) {
|
||||
id, ok := ctx.Value(driverIDKey).(string)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// EmailFromContext retrieves the email from the context.
|
||||
func EmailFromContext(ctx context.Context) (string, bool) {
|
||||
email, ok := ctx.Value(emailKey).(string)
|
||||
return email, ok
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const testSecret = "my-super-secret-supabase-jwt-key"
|
||||
|
||||
func generateTestToken(sub string, email string, expiresAt time.Time, secret string) (string, error) {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub": sub,
|
||||
"email": email,
|
||||
"exp": expiresAt.Unix(),
|
||||
})
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func TestValidateJWT(t *testing.T) {
|
||||
// 1. Valid token
|
||||
validToken, err := generateTestToken("driver-uuid-123", "driver@test.com", time.Now().Add(time.Hour), testSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate valid token: %v", err)
|
||||
}
|
||||
|
||||
claims, err := ValidateJWT(validToken, testSecret)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
if claims.DriverID != "driver-uuid-123" || claims.Email != "driver@test.com" {
|
||||
t.Errorf("unexpected claims: %+v", claims)
|
||||
}
|
||||
|
||||
// 2. Expired token
|
||||
expiredToken, err := generateTestToken("driver-uuid-123", "driver@test.com", time.Now().Add(-time.Hour), testSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate expired token: %v", err)
|
||||
}
|
||||
|
||||
_, err = ValidateJWT(expiredToken, testSecret)
|
||||
if err == nil {
|
||||
t.Error("expected error for expired token, got nil")
|
||||
}
|
||||
|
||||
// 3. Invalid signature
|
||||
_, err = ValidateJWT(validToken, "wrong-secret-key")
|
||||
if err == nil {
|
||||
t.Error("expected error for wrong signature, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware(t *testing.T) {
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
driverID, ok := DriverIDFromContext(r.Context())
|
||||
if ok {
|
||||
w.Header().Set("X-Driver-ID", driverID)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// 1. DevMode = true: bypass authentication
|
||||
mwDev := Middleware(testSecret, true)(nextHandler)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/races", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mwDev.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 OK in DevMode, got %d", w.Code)
|
||||
}
|
||||
|
||||
// 2. DevMode = false: missing Authorization header
|
||||
mwProd := Middleware(testSecret, false)(nextHandler)
|
||||
w = httptest.NewRecorder()
|
||||
mwProd.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 Unauthorized for missing auth header, got %d", w.Code)
|
||||
}
|
||||
|
||||
// 3. DevMode = false: invalid Authorization format
|
||||
reqFormat := httptest.NewRequest(http.MethodGet, "/api/races", nil)
|
||||
reqFormat.Header.Set("Authorization", "InvalidFormat token123")
|
||||
w = httptest.NewRecorder()
|
||||
mwProd.ServeHTTP(w, reqFormat)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 Unauthorized for invalid format, got %d", w.Code)
|
||||
}
|
||||
|
||||
// 4. DevMode = false: valid token
|
||||
validToken, err := generateTestToken("driver-uuid-123", "driver@test.com", time.Now().Add(time.Hour), testSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate valid token: %v", err)
|
||||
}
|
||||
|
||||
reqValid := httptest.NewRequest(http.MethodGet, "/api/races", nil)
|
||||
reqValid.Header.Set("Authorization", "Bearer "+validToken)
|
||||
w = httptest.NewRecorder()
|
||||
mwProd.ServeHTTP(w, reqValid)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 OK for valid token, got %d", w.Code)
|
||||
}
|
||||
if w.Header().Get("X-Driver-ID") != "driver-uuid-123" {
|
||||
t.Errorf("expected X-Driver-ID header to be set in context, got %q", w.Header().Get("X-Driver-ID"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextHelpers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if _, ok := DriverIDFromContext(ctx); ok {
|
||||
t.Error("expected ok=false for empty context")
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, driverIDKey, "test-driver")
|
||||
id, ok := DriverIDFromContext(ctx)
|
||||
if !ok || id != "test-driver" {
|
||||
t.Errorf("expected test-driver, got %s (ok=%t)", id, ok)
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,7 @@ type CreateCarOptions struct {
|
||||
ColorHex string
|
||||
AvatarURL string
|
||||
Active *bool // optional; defaults to true
|
||||
DeviceID *int
|
||||
}
|
||||
|
||||
// Validate sanity-checks dimensions and required fields.
|
||||
@@ -319,6 +320,7 @@ func (s *Service) CreateCar(ctx context.Context, opts CreateCarOptions) (CarMeta
|
||||
ColorHex: opts.ColorHex,
|
||||
AvatarURL: opts.AvatarURL,
|
||||
Active: active,
|
||||
DeviceID: opts.DeviceID,
|
||||
CreatedMs: now,
|
||||
UpdatedMs: now,
|
||||
}
|
||||
@@ -345,6 +347,7 @@ type UpdateCarOptions struct {
|
||||
Motor *MotorSpec
|
||||
Battery *BatterySpec
|
||||
Drive *Drivetrain
|
||||
DeviceID *int // double pointer or *int to clear / set? Let's use *int, but to allow setting to nil (e.g. if we pass -1 or use *int), wait, actually, we can support DeviceID **int or *int. Let's use *int for now or wait, if *int is nil, it means do not update. If they want to set to a device ID, they pass a pointer. Wait! What if they want to clear it? They can pass a pointer to a negative value like -1 or we can use a double pointer. Actually, in Go, *int is very simple. Let's make it *int. If we need to clear, we can pass a device ID of -1 and map it to nil. Let's check how the JSON schema handles it. Usually *int is sufficient. Wait, let's use *int.
|
||||
}
|
||||
|
||||
// UpdateCar applies a partial update.
|
||||
@@ -403,6 +406,13 @@ func (s *Service) UpdateCar(ctx context.Context, id string, opts UpdateCarOption
|
||||
if opts.Drive != nil {
|
||||
c.Drive = *opts.Drive
|
||||
}
|
||||
if opts.DeviceID != nil {
|
||||
if *opts.DeviceID < 0 {
|
||||
c.DeviceID = nil
|
||||
} else {
|
||||
c.DeviceID = opts.DeviceID
|
||||
}
|
||||
}
|
||||
c.UpdatedMs = time.Now().UnixMilli()
|
||||
|
||||
if c.LengthMm < minCarLengthMm || c.LengthMm > maxCarLengthMm ||
|
||||
@@ -606,4 +616,45 @@ func (s *Service) ListCarsByOwner(ownerID string) []CarMeta {
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetTrackCalendar returns all track calendar entries from the store.
|
||||
func (s *Service) GetTrackCalendar(ctx context.Context) ([]TrackCalendarEntry, error) {
|
||||
if s.store == nil {
|
||||
return nil, ErrStoreMissing
|
||||
}
|
||||
return s.store.GetTrackCalendar(ctx)
|
||||
}
|
||||
|
||||
// CreateTrackCalendar creates a new calendar entry.
|
||||
func (s *Service) CreateTrackCalendar(ctx context.Context, trackID string, startAt, endAt time.Time) (TrackCalendarEntry, error) {
|
||||
if s.store == nil {
|
||||
return TrackCalendarEntry{}, ErrStoreMissing
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
_, trackExists := s.tracks[TrackID(trackID)]
|
||||
s.mu.RUnlock()
|
||||
if !trackExists {
|
||||
return TrackCalendarEntry{}, fmt.Errorf("%w: track %s does not exist", ErrNotFound, trackID)
|
||||
}
|
||||
|
||||
if startAt.After(endAt) || startAt.Equal(endAt) {
|
||||
return TrackCalendarEntry{}, fmt.Errorf("%w: start time must be before end time", ErrInvalidInput)
|
||||
}
|
||||
|
||||
entry := TrackCalendarEntry{
|
||||
TrackID: trackID,
|
||||
StartAt: startAt,
|
||||
EndAt: endAt,
|
||||
}
|
||||
return s.store.CreateTrackCalendar(ctx, entry)
|
||||
}
|
||||
|
||||
// DeleteTrackCalendar deletes a calendar entry.
|
||||
func (s *Service) DeleteTrackCalendar(ctx context.Context, id int) error {
|
||||
if s.store == nil {
|
||||
return ErrStoreMissing
|
||||
}
|
||||
return s.store.DeleteTrackCalendar(ctx, id)
|
||||
}
|
||||
@@ -398,4 +398,54 @@ func TestServiceWithoutStore(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error when store is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackCalendar(t *testing.T) {
|
||||
s, _ := loadSeeds()
|
||||
defer s.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. Initially calendar should be empty
|
||||
entries, err := s.GetTrackCalendar(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("get calendar: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("expected 0 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
// 2. Schedule a track
|
||||
start := time.Now().Add(-1 * time.Hour)
|
||||
end := time.Now().Add(1 * time.Hour)
|
||||
created, err := s.CreateTrackCalendar(ctx, "monaco", start, end)
|
||||
if err != nil {
|
||||
t.Fatalf("create calendar: %v", err)
|
||||
}
|
||||
if created.TrackID != "monaco" || !created.StartAt.Equal(start) || !created.EndAt.Equal(end) {
|
||||
t.Errorf("invalid created entry: %+v", created)
|
||||
}
|
||||
|
||||
// 3. Get calendar should return the entry
|
||||
entries, err = s.GetTrackCalendar(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("get calendar: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].ID != created.ID {
|
||||
t.Errorf("expected 1 entry with id %d, got %v", created.ID, entries)
|
||||
}
|
||||
|
||||
// 4. Delete entry
|
||||
err = s.DeleteTrackCalendar(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("delete calendar: %v", err)
|
||||
}
|
||||
|
||||
// 5. Get calendar should be empty again
|
||||
entries, err = s.GetTrackCalendar(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("get calendar: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("expected 0 entries, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,23 @@ import "math"
|
||||
// (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{
|
||||
tracks := []Track{
|
||||
Monaco(),
|
||||
BarcelonaCatalunya(),
|
||||
AustriaRedBullRing(),
|
||||
GreatBritainSilverstone(),
|
||||
BelgiumSpa(),
|
||||
}
|
||||
for i := range tracks {
|
||||
for j := range tracks[i].Centerline {
|
||||
tracks[i].Centerline[j].Y = -tracks[i].Centerline[j].Y
|
||||
tracks[i].Centerline[j].HeadingRad = -tracks[i].Centerline[j].HeadingRad
|
||||
}
|
||||
tracks[i].Bounds = ComputeBounds(tracks[i].Centerline)
|
||||
tracks[i].LengthM = tracks[i].Bounds.LengthM
|
||||
tracks[i].WidthM = tracks[i].Bounds.WidthM
|
||||
}
|
||||
return tracks
|
||||
}
|
||||
|
||||
func DefaultCars() []Car {
|
||||
|
||||
@@ -27,4 +27,9 @@ type Store interface {
|
||||
// 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
|
||||
|
||||
// Track Calendar methods
|
||||
GetTrackCalendar(ctx context.Context) ([]TrackCalendarEntry, error)
|
||||
CreateTrackCalendar(ctx context.Context, entry TrackCalendarEntry) (TrackCalendarEntry, error)
|
||||
DeleteTrackCalendar(ctx context.Context, id int) error
|
||||
}
|
||||
@@ -8,15 +8,18 @@ import (
|
||||
// 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
|
||||
mu sync.Mutex
|
||||
tracks map[TrackID]TrackMeta
|
||||
cars map[CarID]CarMeta
|
||||
calendar []TrackCalendarEntry
|
||||
nextID int
|
||||
}
|
||||
|
||||
func newMemoryStore() *memoryStore {
|
||||
return &memoryStore{
|
||||
tracks: make(map[TrackID]TrackMeta),
|
||||
cars: make(map[CarID]CarMeta),
|
||||
nextID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,4 +75,32 @@ func (m *memoryStore) UpdateCarStats(_ context.Context, id string, fn func(*CarM
|
||||
fn(&c)
|
||||
m.cars[id] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) GetTrackCalendar(_ context.Context) ([]TrackCalendarEntry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := append([]TrackCalendarEntry(nil), m.calendar...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) CreateTrackCalendar(_ context.Context, entry TrackCalendarEntry) (TrackCalendarEntry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
entry.ID = m.nextID
|
||||
m.nextID++
|
||||
m.calendar = append(m.calendar, entry)
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) DeleteTrackCalendar(_ context.Context, id int) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for i, entry := range m.calendar {
|
||||
if entry.ID == id {
|
||||
m.calendar = append(m.calendar[:i], m.calendar[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
@@ -94,6 +94,14 @@ type TrackMeta struct {
|
||||
UpdatedMs int64 `json:"updated_ms"`
|
||||
}
|
||||
|
||||
// TrackCalendarEntry describes one active period for a track.
|
||||
type TrackCalendarEntry struct {
|
||||
ID int `json:"id"`
|
||||
TrackID string `json:"track_id"`
|
||||
StartAt time.Time `json:"start_at"`
|
||||
EndAt time.Time `json:"end_at"`
|
||||
}
|
||||
|
||||
// MotorSpec describes the electric motor.
|
||||
type MotorSpec struct {
|
||||
Kind string `json:"kind"` // brushed | brushless
|
||||
@@ -138,6 +146,7 @@ type CarMeta struct {
|
||||
Motor MotorSpec `json:"motor"`
|
||||
Battery BatterySpec `json:"battery"`
|
||||
Drive Drivetrain `json:"drive"`
|
||||
DeviceID *int `json:"device_id,omitempty"` // mapped physical ESP32 device ID
|
||||
|
||||
// Performance envelope.
|
||||
TopSpeedMs float64 `json:"top_speed_ms"`
|
||||
|
||||
@@ -10,14 +10,14 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// Service composes validation on top of PgStore.
|
||||
// Service composes validation on top of Store.
|
||||
type Service struct {
|
||||
pg *PgStore
|
||||
pg Store
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService wires a service.
|
||||
func NewService(pg *PgStore) *Service {
|
||||
func NewService(pg Store) *Service {
|
||||
return &Service{pg: pg, now: time.Now}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package clans
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type memoryStore struct {
|
||||
clans map[string]Clan
|
||||
}
|
||||
|
||||
func newMemoryStore() *memoryStore {
|
||||
return &memoryStore{clans: make(map[string]Clan)}
|
||||
}
|
||||
|
||||
func (m *memoryStore) Exec(ctx context.Context, sql string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Create(ctx context.Context, c Clan) error {
|
||||
for _, existing := range m.clans {
|
||||
if existing.Tag == c.Tag {
|
||||
return &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint"}
|
||||
}
|
||||
}
|
||||
m.clans[c.ID] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Get(ctx context.Context, id string) (Clan, error) {
|
||||
c, ok := m.clans[id]
|
||||
if !ok {
|
||||
return Clan{}, ErrNotFound
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) GetByTag(ctx context.Context, tag string) (Clan, error) {
|
||||
for _, c := range m.clans {
|
||||
if c.Tag == tag {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return Clan{}, ErrNotFound
|
||||
}
|
||||
|
||||
func (m *memoryStore) List(ctx context.Context, limit, offset int) ([]Clan, error) {
|
||||
out := make([]Clan, 0)
|
||||
for _, c := range m.clans {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Delete(ctx context.Context, id string) error {
|
||||
if _, ok := m.clans[id]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(m.clans, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Update(ctx context.Context, c Clan) error {
|
||||
if _, ok := m.clans[c.ID]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
m.clans[c.ID] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Count(ctx context.Context) (int, error) {
|
||||
return len(m.clans), nil
|
||||
}
|
||||
|
||||
func TestClansService(t *testing.T) {
|
||||
store := newMemoryStore()
|
||||
svc := NewService(store)
|
||||
svc.now = func() time.Time {
|
||||
return time.Unix(1700000000, 0)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. Create a clan with valid inputs
|
||||
c1, err := svc.Create(ctx, CreateInput{
|
||||
ID: "clan-1",
|
||||
Tag: "SPD",
|
||||
Name: "Speed Demons",
|
||||
AvatarURL: "https://example.com/spd.png",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
if c1.ID != "clan-1" || c1.Tag != "SPD" || c1.Name != "Speed Demons" {
|
||||
t.Errorf("Unexpected created clan: %+v", c1)
|
||||
}
|
||||
|
||||
// 2. Validate Tag must be 3 uppercase ASCII letters
|
||||
_, err = svc.Create(ctx, CreateInput{Tag: "SPEED", Name: "toolongtag"})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("Expected ErrInvalidInput for tag 'SPEED', got: %v", err)
|
||||
}
|
||||
_, err = svc.Create(ctx, CreateInput{Tag: "S1D", Name: "withnumbertag"})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("Expected ErrInvalidInput for tag 'S1D', got: %v", err)
|
||||
}
|
||||
|
||||
// 3. Validate empty Name is rejected
|
||||
_, err = svc.Create(ctx, CreateInput{Tag: "AAA", Name: ""})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("Expected ErrInvalidInput for empty name, got: %v", err)
|
||||
}
|
||||
|
||||
// 4. Duplicate tag check
|
||||
_, err = svc.Create(ctx, CreateInput{
|
||||
ID: "clan-2",
|
||||
Tag: "SPD",
|
||||
Name: "Duplicate SPD Tag",
|
||||
})
|
||||
if !errors.Is(err, ErrAlreadyExists) {
|
||||
t.Errorf("Expected ErrAlreadyExists for duplicate tag, got: %v", err)
|
||||
}
|
||||
|
||||
// 5. Get clan
|
||||
got, err := svc.Get(ctx, "clan-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Get failed: %v", err)
|
||||
}
|
||||
if got.Tag != "SPD" {
|
||||
t.Errorf("Expected SPD, got %s", got.Tag)
|
||||
}
|
||||
|
||||
// 6. Get by tag
|
||||
gotByTag, err := svc.GetByTag(ctx, "spd") // should normalize to SPD
|
||||
if err != nil {
|
||||
t.Fatalf("GetByTag failed: %v", err)
|
||||
}
|
||||
if gotByTag.ID != "clan-1" {
|
||||
t.Errorf("Expected clan-1, got %s", gotByTag.ID)
|
||||
}
|
||||
|
||||
// 7. Update clan
|
||||
updated, err := svc.Update(ctx, "clan-1", UpdateInput{
|
||||
Name: "Super Speed Demons",
|
||||
AvatarURL: "https://example.com/super-spd.png",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
if updated.Name != "Super Speed Demons" || updated.AvatarURL != "https://example.com/super-spd.png" {
|
||||
t.Errorf("Unexpected updated clan fields: %+v", updated)
|
||||
}
|
||||
|
||||
// 8. Delete clan
|
||||
err = svc.Delete(ctx, "clan-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
_, err = svc.Get(ctx, "clan-1")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Expected ErrNotFound after deletion, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,18 @@ type Clan struct {
|
||||
UpdatedMs int64
|
||||
}
|
||||
|
||||
// Store defines the persistence operations for clans.
|
||||
type Store interface {
|
||||
Exec(ctx context.Context, sql string) (int64, error)
|
||||
Create(ctx context.Context, c Clan) error
|
||||
Get(ctx context.Context, id string) (Clan, error)
|
||||
GetByTag(ctx context.Context, tag string) (Clan, error)
|
||||
List(ctx context.Context, limit, offset int) ([]Clan, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
Update(ctx context.Context, c Clan) error
|
||||
Count(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// PgStore is the Postgres-backed half of the package.
|
||||
type PgStore struct {
|
||||
pool *pgxpool.Pool
|
||||
|
||||
@@ -26,6 +26,7 @@ type Config struct {
|
||||
DevMode bool // If true, allow multiple clients without auth
|
||||
|
||||
DatabaseURL string // Postgres connection string; required at runtime
|
||||
JWTSecret string // Supabase JWT Secret for signature verification
|
||||
|
||||
UDPVideoAddr string // UDP address to listen on for video, e.g. ":9999"
|
||||
ESPCmdPort int // Port on ESP32 that listens for commands, e.g. 8888
|
||||
@@ -39,6 +40,14 @@ type Config struct {
|
||||
func Load() (*Config, error) {
|
||||
loadDotEnv()
|
||||
|
||||
jwtSecret := getEnv("X0GP_JWT_SECRET", "")
|
||||
if jwtSecret == "" {
|
||||
jwtSecret = getEnv("JWT_SECRET", "")
|
||||
}
|
||||
if jwtSecret == "" {
|
||||
jwtSecret = getEnv("SUPABASE_JWT_SECRET", "")
|
||||
}
|
||||
|
||||
c := &Config{
|
||||
HTTPAddr: getEnv("X0GP_HTTP_ADDR", ":8080"),
|
||||
TickRate: getEnvInt("X0GP_TICK_RATE", 60),
|
||||
@@ -47,10 +56,15 @@ func Load() (*Config, error) {
|
||||
SnapshotRate: getEnvInt("X0GP_SNAPSHOT_RATE", 30),
|
||||
DevMode: getEnvBool("X0GP_DEV_MODE", true),
|
||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||
JWTSecret: jwtSecret,
|
||||
UDPVideoAddr: getEnv("X0GP_UDP_VIDEO_ADDR", ":9999"),
|
||||
ESPCmdPort: getEnvInt("X0GP_ESP_CMD_PORT", 8888),
|
||||
}
|
||||
|
||||
if !c.DevMode && c.JWTSecret == "" {
|
||||
return nil, fmt.Errorf("JWT secret (X0GP_JWT_SECRET / JWT_SECRET) is required when DevMode is disabled")
|
||||
}
|
||||
|
||||
if c.TickRate <= 0 || c.TickRate > 240 {
|
||||
return nil, fmt.Errorf("X0GP_TICK_RATE must be in (0, 240], got %d", c.TickRate)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ type Car struct {
|
||||
ID string
|
||||
DriverID string // session id
|
||||
DriverName string
|
||||
DeviceID *int // internal mapping to physical ESP32 device ID
|
||||
X, Y float64
|
||||
Heading float64 // radians
|
||||
Speed float64 // m/s
|
||||
@@ -169,7 +170,7 @@ 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) {
|
||||
func (e *Engine) AddCar(driverID, driverName string, slot int, deviceID *int) (*Car, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
@@ -185,6 +186,7 @@ func (e *Engine) AddCar(driverID, driverName string, slot int) (*Car, error) {
|
||||
ID: id,
|
||||
DriverID: driverID,
|
||||
DriverName: driverName,
|
||||
DeviceID: deviceID,
|
||||
// Spread cars along start grid (x = 0, y = slot * 0.4 m).
|
||||
Y: float64(slot) * 0.4,
|
||||
}
|
||||
|
||||
@@ -10,14 +10,14 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// Service composes validation on top of PgStore.
|
||||
// Service composes validation on top of Store.
|
||||
type Service struct {
|
||||
pg *PgStore
|
||||
pg Store
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService wires a service.
|
||||
func NewService(pg *PgStore) *Service {
|
||||
func NewService(pg Store) *Service {
|
||||
return &Service{pg: pg, now: time.Now}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ func (s *Service) List(ctx context.Context, limit, offset int, clanID string) ([
|
||||
|
||||
// UpdateInput is the patch payload.
|
||||
type UpdateInput struct {
|
||||
Nickname string
|
||||
Name string
|
||||
AvatarURL string
|
||||
ClanID *string // nil = leave unchanged, &"" = clear, &"..." = set
|
||||
@@ -89,6 +90,13 @@ func (s *Service) Update(ctx context.Context, id string, in UpdateInput) (Driver
|
||||
if err != nil {
|
||||
return Driver{}, err
|
||||
}
|
||||
if in.Nickname != "" {
|
||||
nick := strings.ToUpper(strings.TrimSpace(in.Nickname))
|
||||
if !IsValidNickname(nick) {
|
||||
return Driver{}, fmt.Errorf("%w: nickname must be 3 uppercase ASCII letters", ErrInvalidInput)
|
||||
}
|
||||
cur.Nickname = nick
|
||||
}
|
||||
if in.Name != "" {
|
||||
cur.Name = in.Name
|
||||
}
|
||||
@@ -99,6 +107,10 @@ func (s *Service) Update(ctx context.Context, id string, in UpdateInput) (Driver
|
||||
cur.ClanID = *in.ClanID
|
||||
}
|
||||
if err := s.pg.Update(ctx, cur); err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return Driver{}, fmt.Errorf("%w: %s", ErrAlreadyExists, cur.Nickname)
|
||||
}
|
||||
return Driver{}, err
|
||||
}
|
||||
return s.pg.Get(ctx, id)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type memoryStore struct {
|
||||
drivers map[string]Driver
|
||||
}
|
||||
|
||||
func newMemoryStore() *memoryStore {
|
||||
return &memoryStore{drivers: make(map[string]Driver)}
|
||||
}
|
||||
|
||||
func (m *memoryStore) Exec(ctx context.Context, sql string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Create(ctx context.Context, d Driver) error {
|
||||
for _, existing := range m.drivers {
|
||||
if existing.Nickname == d.Nickname {
|
||||
return &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint"}
|
||||
}
|
||||
}
|
||||
m.drivers[d.ID] = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Get(ctx context.Context, id string) (Driver, error) {
|
||||
d, ok := m.drivers[id]
|
||||
if !ok {
|
||||
return Driver{}, ErrNotFound
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) GetByNickname(ctx context.Context, nick string) (Driver, error) {
|
||||
for _, d := range m.drivers {
|
||||
if d.Nickname == nick {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
return Driver{}, ErrNotFound
|
||||
}
|
||||
|
||||
func (m *memoryStore) List(ctx context.Context, limit, offset int, clanID string) ([]Driver, error) {
|
||||
out := make([]Driver, 0)
|
||||
for _, d := range m.drivers {
|
||||
if clanID == "" || d.ClanID == clanID {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Delete(ctx context.Context, id string) error {
|
||||
if _, ok := m.drivers[id]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(m.drivers, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Update(ctx context.Context, d Driver) error {
|
||||
if _, ok := m.drivers[d.ID]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
for id, existing := range m.drivers {
|
||||
if id != d.ID && existing.Nickname == d.Nickname {
|
||||
return &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint"}
|
||||
}
|
||||
}
|
||||
m.drivers[d.ID] = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Count(ctx context.Context) (int, error) {
|
||||
return len(m.drivers), nil
|
||||
}
|
||||
|
||||
func TestDriversService(t *testing.T) {
|
||||
store := newMemoryStore()
|
||||
svc := NewService(store)
|
||||
svc.now = func() time.Time {
|
||||
return time.Unix(1700000000, 0)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. Create a driver
|
||||
d1, err := svc.Create(ctx, CreateInput{
|
||||
ID: "driver-1",
|
||||
Nickname: "HAM",
|
||||
Name: "Lewis Hamilton",
|
||||
AvatarURL: "https://example.com/ham.png",
|
||||
ClanID: "clan-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
if d1.ID != "driver-1" || d1.Nickname != "HAM" || d1.Name != "Lewis Hamilton" || d1.ClanID != "clan-1" {
|
||||
t.Errorf("Unexpected created driver: %+v", d1)
|
||||
}
|
||||
|
||||
// 2. Validate Nickname must be 3 uppercase ASCII letters
|
||||
_, err = svc.Create(ctx, CreateInput{Nickname: "HAMILTON", Name: "toolong"})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("Expected ErrInvalidInput for tag 'HAMILTON', got: %v", err)
|
||||
}
|
||||
|
||||
// 3. Validate empty Name is rejected
|
||||
_, err = svc.Create(ctx, CreateInput{Nickname: "BOT", Name: ""})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("Expected ErrInvalidInput for empty name, got: %v", err)
|
||||
}
|
||||
|
||||
// 4. Duplicate nickname check
|
||||
_, err = svc.Create(ctx, CreateInput{
|
||||
ID: "driver-2",
|
||||
Nickname: "HAM",
|
||||
Name: "Another Lewis",
|
||||
})
|
||||
if !errors.Is(err, ErrAlreadyExists) {
|
||||
t.Errorf("Expected ErrAlreadyExists for duplicate nickname, got: %v", err)
|
||||
}
|
||||
|
||||
// 5. Get driver
|
||||
got, err := svc.Get(ctx, "driver-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Get failed: %v", err)
|
||||
}
|
||||
if got.Nickname != "HAM" {
|
||||
t.Errorf("Expected HAM, got %s", got.Nickname)
|
||||
}
|
||||
|
||||
// 6. Get by nickname
|
||||
gotByNick, err := svc.GetByNickname(ctx, "ham") // should normalize to HAM
|
||||
if err != nil {
|
||||
t.Fatalf("GetByNickname failed: %v", err)
|
||||
}
|
||||
if gotByNick.ID != "driver-1" {
|
||||
t.Errorf("Expected driver-1, got %s", gotByNick.ID)
|
||||
}
|
||||
|
||||
// 7. Update driver (including nickname)
|
||||
clanID := "clan-2"
|
||||
updated, err := svc.Update(ctx, "driver-1", UpdateInput{
|
||||
Nickname: "VET",
|
||||
Name: "Lewis Hamilton MBE",
|
||||
AvatarURL: "https://example.com/ham-mbe.png",
|
||||
ClanID: &clanID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
if updated.Nickname != "VET" || updated.Name != "Lewis Hamilton MBE" || updated.AvatarURL != "https://example.com/ham-mbe.png" || updated.ClanID != "clan-2" {
|
||||
t.Errorf("Unexpected updated driver fields: %+v", updated)
|
||||
}
|
||||
|
||||
// 7a. Update with invalid nickname
|
||||
_, err = svc.Update(ctx, "driver-1", UpdateInput{Nickname: "TOOLONG"})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("Expected ErrInvalidInput for invalid nickname update, got: %v", err)
|
||||
}
|
||||
|
||||
// 7b. Create driver-2 to test duplicate nickname conflict
|
||||
_, err = svc.Create(ctx, CreateInput{
|
||||
ID: "driver-2",
|
||||
Nickname: "HAM",
|
||||
Name: "Lewis",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create driver-2 failed: %v", err)
|
||||
}
|
||||
|
||||
// 7c. Update driver-2 to nickname "VET" (should fail, already taken by driver-1)
|
||||
_, err = svc.Update(ctx, "driver-2", UpdateInput{Nickname: "VET"})
|
||||
if !errors.Is(err, ErrAlreadyExists) {
|
||||
t.Errorf("Expected ErrAlreadyExists for duplicate nickname update, got: %v", err)
|
||||
}
|
||||
|
||||
// 8. Delete driver
|
||||
err = svc.Delete(ctx, "driver-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
_, err = svc.Get(ctx, "driver-1")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Expected ErrNotFound after deletion, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,18 @@ type Driver struct {
|
||||
UpdatedMs int64
|
||||
}
|
||||
|
||||
// Store defines the persistence operations for drivers.
|
||||
type Store interface {
|
||||
Exec(ctx context.Context, sql string) (int64, error)
|
||||
Create(ctx context.Context, d Driver) error
|
||||
Get(ctx context.Context, id string) (Driver, error)
|
||||
GetByNickname(ctx context.Context, nick string) (Driver, error)
|
||||
List(ctx context.Context, limit, offset int, clanID string) ([]Driver, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
Update(ctx context.Context, d Driver) error
|
||||
Count(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// PgStore is the Postgres-backed half of the package.
|
||||
type PgStore struct {
|
||||
pool *pgxpool.Pool
|
||||
@@ -153,9 +165,9 @@ func (s *PgStore) Update(ctx context.Context, d Driver) error {
|
||||
d.UpdatedMs = time.Now().UnixMilli()
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE drivers
|
||||
SET name = $2, avatar_url = $3, clan_id = $4, updated_ms = $5
|
||||
SET nickname = $2, name = $3, avatar_url = $4, clan_id = $5, updated_ms = $6
|
||||
WHERE id = $1`,
|
||||
d.ID, d.Name, d.AvatarURL, nullableClan(d.ClanID), d.UpdatedMs)
|
||||
d.ID, d.Nickname, d.Name, d.AvatarURL, nullableClan(d.ClanID), d.UpdatedMs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ func (s *Service) RemoveDriver(id string) {
|
||||
d.Status = DriverStatusOffline
|
||||
d.LastSeenMs = time.Now().UnixMilli()
|
||||
d.RaceID = ""
|
||||
d.DeviceID = nil
|
||||
d.CarID = nil
|
||||
hook := s.onRaceRemoved
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -244,9 +244,9 @@ func (s *Service) AddDriverToRace(driverID, raceID string) error {
|
||||
s.mu.Unlock()
|
||||
return ErrDriverNotFound
|
||||
}
|
||||
if d.DeviceID == nil {
|
||||
if d.CarID == nil || *d.CarID == "" {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("driver %s has no device selected", driverID)
|
||||
return fmt.Errorf("driver %s has no car selected", driverID)
|
||||
}
|
||||
r, ok := s.races[raceID]
|
||||
if !ok {
|
||||
@@ -306,7 +306,7 @@ func (s *Service) RemoveDriverFromRace(driverID string) {
|
||||
}
|
||||
d.Status = DriverStatusIdle
|
||||
d.RaceID = ""
|
||||
d.DeviceID = nil
|
||||
d.CarID = nil
|
||||
prevRaceID := d.RaceID
|
||||
hook := s.onRaceRemoved
|
||||
s.mu.Unlock()
|
||||
@@ -349,7 +349,7 @@ func (s *Service) SetRaceStatus(raceID string, status RaceStatus) error {
|
||||
if d, ok := s.drivers[did]; ok {
|
||||
d.Status = DriverStatusIdle
|
||||
d.RaceID = ""
|
||||
d.DeviceID = nil
|
||||
d.CarID = nil
|
||||
affected = append(affected, *d)
|
||||
}
|
||||
}
|
||||
@@ -454,16 +454,10 @@ func (s *Service) GetDriver(driverID string) (DriverMeta, error) {
|
||||
return *d, nil
|
||||
}
|
||||
|
||||
// SelectDevice assigns a physical device ID (0..127) to a driver.
|
||||
// Pass deviceID = nil to release the device.
|
||||
// Returns an error if the device is already taken by another online driver.
|
||||
func (s *Service) SelectDevice(driverID string, deviceID *int) error {
|
||||
if deviceID != nil {
|
||||
if *deviceID < 0 || *deviceID > 127 {
|
||||
return fmt.Errorf("invalid device ID: must be 0..127")
|
||||
}
|
||||
}
|
||||
|
||||
// SelectCar assigns a catalog car ID to a driver.
|
||||
// If carID is nil, it clears the driver's car assignment.
|
||||
// It returns an error if the car is already taken by another active driver.
|
||||
func (s *Service) SelectCar(driverID string, carID *string) error {
|
||||
s.mu.Lock()
|
||||
d, ok := s.drivers[driverID]
|
||||
if !ok {
|
||||
@@ -471,17 +465,17 @@ func (s *Service) SelectDevice(driverID string, deviceID *int) error {
|
||||
return ErrDriverNotFound
|
||||
}
|
||||
|
||||
// Enforce that device cannot be selected if already assigned to another driver.
|
||||
if deviceID != nil {
|
||||
// Enforce that car cannot be selected if already assigned to another driver.
|
||||
if carID != nil && *carID != "" {
|
||||
for otherID, otherD := range s.drivers {
|
||||
if otherID != driverID && otherD.Status != DriverStatusOffline && otherD.DeviceID != nil && *otherD.DeviceID == *deviceID {
|
||||
if otherID != driverID && otherD.Status != DriverStatusOffline && otherD.CarID != nil && *otherD.CarID == *carID {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("device %d is already taken by driver %s", *deviceID, otherID)
|
||||
return fmt.Errorf("car %s is already taken by driver %s", *carID, otherID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d.DeviceID = deviceID
|
||||
d.CarID = carID
|
||||
out := *d
|
||||
s.mu.Unlock()
|
||||
|
||||
|
||||
@@ -75,9 +75,9 @@ func TestQuickPlayJoinsFirstRace(t *testing.T) {
|
||||
s := NewService()
|
||||
_, _ = s.AddDriver("d1", "Alice", "")
|
||||
_, _ = s.AddDriver("d2", "Bob", "")
|
||||
dev1, dev2 := 1, 2
|
||||
_ = s.SelectDevice("d1", &dev1)
|
||||
_ = s.SelectDevice("d2", &dev2)
|
||||
car1, car2 := "car-1", "car-2"
|
||||
_ = s.SelectCar("d1", &car1)
|
||||
_ = s.SelectCar("d2", &car2)
|
||||
r1, _ := s.QuickPlay("d1", 4)
|
||||
r2, err := s.QuickPlay("d2", 4)
|
||||
if err != nil {
|
||||
|
||||
@@ -65,7 +65,7 @@ type DriverMeta struct {
|
||||
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
|
||||
DeviceID *int `json:"device_id,omitempty"`
|
||||
CarID *string `json:"car_id,omitempty"`
|
||||
}
|
||||
|
||||
// Snapshot is the immutable view broadcast to clients.
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
package races
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// LeaderboardEntry is a single row in the leaderboard.
|
||||
type LeaderboardEntry struct {
|
||||
DriverID string
|
||||
Name string
|
||||
Nickname string
|
||||
AvatarURL string
|
||||
ClanID string
|
||||
ClanTag string
|
||||
Points int
|
||||
BestPos *int // deprecated/optional
|
||||
BestTimeMs *int64
|
||||
Rank int
|
||||
}
|
||||
|
||||
// LeaderboardResult groups the paginated items, total count, and current driver.
|
||||
type LeaderboardResult struct {
|
||||
Items []LeaderboardEntry
|
||||
Total int
|
||||
CurrentDriver *LeaderboardEntry
|
||||
}
|
||||
|
||||
func getYearTimestamps(year int) (int64, int64) {
|
||||
start := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(year+1, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
return start.UnixMilli(), end.UnixMilli()
|
||||
}
|
||||
|
||||
// GetTrackLeaderboard returns track-specific leaderboard entries for the given track and year.
|
||||
func (s *PgStore) GetTrackLeaderboard(ctx context.Context, trackID string, year int, limit, offset int) ([]LeaderboardEntry, int, error) {
|
||||
startMs, endMs := getYearTimestamps(year)
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
countQuery := `
|
||||
WITH best_results AS (
|
||||
SELECT
|
||||
rd.driver_id
|
||||
FROM race_drivers rd
|
||||
JOIN races r ON rd.race_id = r.id
|
||||
WHERE r.status = 'finished'
|
||||
AND r.track_id = $1
|
||||
AND r.finished_ms >= $2
|
||||
AND r.finished_ms < $3
|
||||
GROUP BY rd.driver_id
|
||||
)
|
||||
SELECT COUNT(*) FROM best_results`
|
||||
|
||||
var total int
|
||||
err := s.pool.QueryRow(ctx, countQuery, trackID, startMs, endMs).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("count track leaderboard: %w", err)
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
query := `
|
||||
WITH best_results AS (
|
||||
SELECT
|
||||
r.track_id,
|
||||
rd.driver_id,
|
||||
MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms ELSE NULL END) as best_time
|
||||
FROM race_drivers rd
|
||||
JOIN races r ON rd.race_id = r.id
|
||||
WHERE r.status = 'finished'
|
||||
AND r.finished_ms >= $1
|
||||
AND r.finished_ms < $2
|
||||
GROUP BY r.track_id, rd.driver_id
|
||||
),
|
||||
ranked_times AS (
|
||||
SELECT
|
||||
track_id,
|
||||
driver_id,
|
||||
best_time,
|
||||
ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY best_time ASC NULLS LAST, driver_id ASC) as time_rank
|
||||
FROM best_results
|
||||
),
|
||||
track_points AS (
|
||||
SELECT
|
||||
track_id,
|
||||
driver_id,
|
||||
best_time,
|
||||
CASE
|
||||
WHEN best_time IS NULL THEN 1
|
||||
WHEN time_rank <= 3 THEN 25
|
||||
WHEN time_rank <= 10 THEN 18
|
||||
WHEN time_rank <= 20 THEN 15
|
||||
WHEN time_rank <= 30 THEN 12
|
||||
WHEN time_rank <= 40 THEN 10
|
||||
WHEN time_rank <= 50 THEN 8
|
||||
WHEN time_rank <= 60 THEN 6
|
||||
WHEN time_rank <= 70 THEN 4
|
||||
WHEN time_rank <= 80 THEN 2
|
||||
WHEN time_rank <= 90 THEN 1
|
||||
ELSE 1
|
||||
END as points
|
||||
FROM ranked_times
|
||||
),
|
||||
ranked_leaderboard AS (
|
||||
SELECT
|
||||
tp.driver_id,
|
||||
d.name,
|
||||
d.nickname,
|
||||
d.avatar_url,
|
||||
COALESCE(d.clan_id, '') as clan_id,
|
||||
COALESCE(c.tag, '') as clan_tag,
|
||||
tp.points,
|
||||
tp.best_time,
|
||||
ROW_NUMBER() OVER (ORDER BY tp.points DESC, tp.best_time ASC NULLS LAST, tp.driver_id ASC) as rank
|
||||
FROM track_points tp
|
||||
JOIN drivers d ON tp.driver_id = d.id
|
||||
LEFT JOIN clans c ON d.clan_id = c.id
|
||||
WHERE tp.track_id = $3
|
||||
)
|
||||
SELECT rank, driver_id, name, nickname, avatar_url, clan_id, clan_tag, points, best_time
|
||||
FROM ranked_leaderboard
|
||||
ORDER BY rank ASC
|
||||
LIMIT $4 OFFSET $5`
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, startMs, endMs, trackID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("query track leaderboard: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []LeaderboardEntry
|
||||
for rows.Next() {
|
||||
var entry LeaderboardEntry
|
||||
if err := rows.Scan(
|
||||
&entry.Rank, &entry.DriverID, &entry.Name, &entry.Nickname,
|
||||
&entry.AvatarURL, &entry.ClanID, &entry.ClanTag, &entry.Points, &entry.BestTimeMs,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, total, nil
|
||||
}
|
||||
|
||||
// GetTrackLeaderboardDriver returns a single driver's rank on a track.
|
||||
func (s *PgStore) GetTrackLeaderboardDriver(ctx context.Context, trackID string, year int, driverID string) (*LeaderboardEntry, error) {
|
||||
startMs, endMs := getYearTimestamps(year)
|
||||
query := `
|
||||
WITH best_results AS (
|
||||
SELECT
|
||||
r.track_id,
|
||||
rd.driver_id,
|
||||
MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms ELSE NULL END) as best_time
|
||||
FROM race_drivers rd
|
||||
JOIN races r ON rd.race_id = r.id
|
||||
WHERE r.status = 'finished'
|
||||
AND r.finished_ms >= $1
|
||||
AND r.finished_ms < $2
|
||||
GROUP BY r.track_id, rd.driver_id
|
||||
),
|
||||
ranked_times AS (
|
||||
SELECT
|
||||
track_id,
|
||||
driver_id,
|
||||
best_time,
|
||||
ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY best_time ASC NULLS LAST, driver_id ASC) as time_rank
|
||||
FROM best_results
|
||||
),
|
||||
track_points AS (
|
||||
SELECT
|
||||
track_id,
|
||||
driver_id,
|
||||
best_time,
|
||||
CASE
|
||||
WHEN best_time IS NULL THEN 1
|
||||
WHEN time_rank <= 3 THEN 25
|
||||
WHEN time_rank <= 10 THEN 18
|
||||
WHEN time_rank <= 20 THEN 15
|
||||
WHEN time_rank <= 30 THEN 12
|
||||
WHEN time_rank <= 40 THEN 10
|
||||
WHEN time_rank <= 50 THEN 8
|
||||
WHEN time_rank <= 60 THEN 6
|
||||
WHEN time_rank <= 70 THEN 4
|
||||
WHEN time_rank <= 80 THEN 2
|
||||
WHEN time_rank <= 90 THEN 1
|
||||
ELSE 1
|
||||
END as points
|
||||
FROM ranked_times
|
||||
),
|
||||
ranked_leaderboard AS (
|
||||
SELECT
|
||||
tp.driver_id,
|
||||
d.name,
|
||||
d.nickname,
|
||||
d.avatar_url,
|
||||
COALESCE(d.clan_id, '') as clan_id,
|
||||
COALESCE(c.tag, '') as clan_tag,
|
||||
tp.points,
|
||||
tp.best_time,
|
||||
ROW_NUMBER() OVER (ORDER BY tp.points DESC, tp.best_time ASC NULLS LAST, tp.driver_id ASC) as rank
|
||||
FROM track_points tp
|
||||
JOIN drivers d ON tp.driver_id = d.id
|
||||
LEFT JOIN clans c ON d.clan_id = c.id
|
||||
WHERE tp.track_id = $3
|
||||
)
|
||||
SELECT rank, driver_id, name, nickname, avatar_url, clan_id, clan_tag, points, best_time
|
||||
FROM ranked_leaderboard
|
||||
WHERE driver_id = $4`
|
||||
|
||||
row := s.pool.QueryRow(ctx, query, startMs, endMs, trackID, driverID)
|
||||
var entry LeaderboardEntry
|
||||
if err := row.Scan(
|
||||
&entry.Rank, &entry.DriverID, &entry.Name, &entry.Nickname,
|
||||
&entry.AvatarURL, &entry.ClanID, &entry.ClanTag, &entry.Points, &entry.BestTimeMs,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
// GetOverallLeaderboard returns overall leaderboard entries for the given year.
|
||||
func (s *PgStore) GetOverallLeaderboard(ctx context.Context, year int, limit, offset int) ([]LeaderboardEntry, int, error) {
|
||||
startMs, endMs := getYearTimestamps(year)
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
countQuery := `
|
||||
WITH best_results AS (
|
||||
SELECT
|
||||
rd.driver_id
|
||||
FROM race_drivers rd
|
||||
JOIN races r ON rd.race_id = r.id
|
||||
WHERE r.status = 'finished'
|
||||
AND r.finished_ms >= $1
|
||||
AND r.finished_ms < $2
|
||||
GROUP BY rd.driver_id
|
||||
)
|
||||
SELECT COUNT(*) FROM best_results`
|
||||
|
||||
var total int
|
||||
err := s.pool.QueryRow(ctx, countQuery, startMs, endMs).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("count overall leaderboard: %w", err)
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
query := `
|
||||
WITH best_results AS (
|
||||
SELECT
|
||||
r.track_id,
|
||||
rd.driver_id,
|
||||
MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms ELSE NULL END) as best_time
|
||||
FROM race_drivers rd
|
||||
JOIN races r ON rd.race_id = r.id
|
||||
WHERE r.status = 'finished'
|
||||
AND r.finished_ms >= $1
|
||||
AND r.finished_ms < $2
|
||||
GROUP BY r.track_id, rd.driver_id
|
||||
),
|
||||
ranked_times AS (
|
||||
SELECT
|
||||
track_id,
|
||||
driver_id,
|
||||
best_time,
|
||||
ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY best_time ASC NULLS LAST, driver_id ASC) as time_rank
|
||||
FROM best_results
|
||||
),
|
||||
track_points AS (
|
||||
SELECT
|
||||
track_id,
|
||||
driver_id,
|
||||
best_time,
|
||||
CASE
|
||||
WHEN best_time IS NULL THEN 1
|
||||
WHEN time_rank <= 3 THEN 25
|
||||
WHEN time_rank <= 10 THEN 18
|
||||
WHEN time_rank <= 20 THEN 15
|
||||
WHEN time_rank <= 30 THEN 12
|
||||
WHEN time_rank <= 40 THEN 10
|
||||
WHEN time_rank <= 50 THEN 8
|
||||
WHEN time_rank <= 60 THEN 6
|
||||
WHEN time_rank <= 70 THEN 4
|
||||
WHEN time_rank <= 80 THEN 2
|
||||
WHEN time_rank <= 90 THEN 1
|
||||
ELSE 1
|
||||
END as points
|
||||
FROM ranked_times
|
||||
),
|
||||
overall_points AS (
|
||||
SELECT
|
||||
driver_id,
|
||||
SUM(points) as points
|
||||
FROM track_points
|
||||
GROUP BY driver_id
|
||||
),
|
||||
ranked_overall AS (
|
||||
SELECT
|
||||
op.driver_id,
|
||||
d.name,
|
||||
d.nickname,
|
||||
d.avatar_url,
|
||||
COALESCE(d.clan_id, '') as clan_id,
|
||||
COALESCE(c.tag, '') as clan_tag,
|
||||
op.points,
|
||||
ROW_NUMBER() OVER (ORDER BY op.points DESC, op.driver_id ASC) as rank
|
||||
FROM overall_points op
|
||||
JOIN drivers d ON op.driver_id = d.id
|
||||
LEFT JOIN clans c ON d.clan_id = c.id
|
||||
)
|
||||
SELECT rank, driver_id, name, nickname, avatar_url, clan_id, clan_tag, points
|
||||
FROM ranked_overall
|
||||
ORDER BY rank ASC
|
||||
LIMIT $3 OFFSET $4`
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, startMs, endMs, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("query overall leaderboard: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []LeaderboardEntry
|
||||
for rows.Next() {
|
||||
var entry LeaderboardEntry
|
||||
if err := rows.Scan(
|
||||
&entry.Rank, &entry.DriverID, &entry.Name, &entry.Nickname,
|
||||
&entry.AvatarURL, &entry.ClanID, &entry.ClanTag, &entry.Points,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, total, nil
|
||||
}
|
||||
|
||||
// GetOverallLeaderboardDriver returns a single driver's overall rank.
|
||||
func (s *PgStore) GetOverallLeaderboardDriver(ctx context.Context, year int, driverID string) (*LeaderboardEntry, error) {
|
||||
startMs, endMs := getYearTimestamps(year)
|
||||
query := `
|
||||
WITH best_results AS (
|
||||
SELECT
|
||||
r.track_id,
|
||||
rd.driver_id,
|
||||
MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms ELSE NULL END) as best_time
|
||||
FROM race_drivers rd
|
||||
JOIN races r ON rd.race_id = r.id
|
||||
WHERE r.status = 'finished'
|
||||
AND r.finished_ms >= $1
|
||||
AND r.finished_ms < $2
|
||||
GROUP BY r.track_id, rd.driver_id
|
||||
),
|
||||
ranked_times AS (
|
||||
SELECT
|
||||
track_id,
|
||||
driver_id,
|
||||
best_time,
|
||||
ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY best_time ASC NULLS LAST, driver_id ASC) as time_rank
|
||||
FROM best_results
|
||||
),
|
||||
track_points AS (
|
||||
SELECT
|
||||
track_id,
|
||||
driver_id,
|
||||
best_time,
|
||||
CASE
|
||||
WHEN best_time IS NULL THEN 1
|
||||
WHEN time_rank <= 3 THEN 25
|
||||
WHEN time_rank <= 10 THEN 18
|
||||
WHEN time_rank <= 20 THEN 15
|
||||
WHEN time_rank <= 30 THEN 12
|
||||
WHEN time_rank <= 40 THEN 10
|
||||
WHEN time_rank <= 50 THEN 8
|
||||
WHEN time_rank <= 60 THEN 6
|
||||
WHEN time_rank <= 70 THEN 4
|
||||
WHEN time_rank <= 80 THEN 2
|
||||
WHEN time_rank <= 90 THEN 1
|
||||
ELSE 1
|
||||
END as points
|
||||
FROM ranked_times
|
||||
),
|
||||
overall_points AS (
|
||||
SELECT
|
||||
driver_id,
|
||||
SUM(points) as points
|
||||
FROM track_points
|
||||
GROUP BY driver_id
|
||||
),
|
||||
ranked_overall AS (
|
||||
SELECT
|
||||
op.driver_id,
|
||||
d.name,
|
||||
d.nickname,
|
||||
d.avatar_url,
|
||||
COALESCE(d.clan_id, '') as clan_id,
|
||||
COALESCE(c.tag, '') as clan_tag,
|
||||
op.points,
|
||||
ROW_NUMBER() OVER (ORDER BY op.points DESC, op.driver_id ASC) as rank
|
||||
FROM overall_points op
|
||||
JOIN drivers d ON op.driver_id = d.id
|
||||
LEFT JOIN clans c ON d.clan_id = c.id
|
||||
)
|
||||
SELECT rank, driver_id, name, nickname, avatar_url, clan_id, clan_tag, points
|
||||
FROM ranked_overall
|
||||
WHERE driver_id = $3`
|
||||
|
||||
row := s.pool.QueryRow(ctx, query, startMs, endMs, driverID)
|
||||
var entry LeaderboardEntry
|
||||
if err := row.Scan(
|
||||
&entry.Rank, &entry.DriverID, &entry.Name, &entry.Nickname,
|
||||
&entry.AvatarURL, &entry.ClanID, &entry.ClanTag, &entry.Points,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
// GetDriverLeaderboardProfile returns driver profile information.
|
||||
func (s *PgStore) GetDriverLeaderboardProfile(ctx context.Context, driverID string) (*LeaderboardEntry, error) {
|
||||
query := `
|
||||
SELECT d.id, d.name, d.nickname, d.avatar_url, COALESCE(d.clan_id, ''), COALESCE(c.tag, '')
|
||||
FROM drivers d
|
||||
LEFT JOIN clans c ON d.clan_id = c.id
|
||||
WHERE d.id = $1`
|
||||
|
||||
row := s.pool.QueryRow(ctx, query, driverID)
|
||||
var entry LeaderboardEntry
|
||||
if err := row.Scan(
|
||||
&entry.DriverID, &entry.Name, &entry.Nickname, &entry.AvatarURL, &entry.ClanID, &entry.ClanTag,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
// TrackCalendarInfo holds the basic details of a scheduled track.
|
||||
type TrackCalendarInfo struct {
|
||||
ID int
|
||||
TrackID string
|
||||
TrackName string
|
||||
StartAt time.Time
|
||||
EndAt time.Time
|
||||
}
|
||||
|
||||
// TrackCalendarLeaderboard holds the leaderboard summary for a track calendar event.
|
||||
type TrackCalendarLeaderboard struct {
|
||||
TrackID string
|
||||
TrackName string
|
||||
StartAt time.Time
|
||||
EndAt time.Time
|
||||
Status string // finished, active, planned
|
||||
Podium []LeaderboardEntry
|
||||
CurrentDriver *LeaderboardEntry
|
||||
}
|
||||
|
||||
// GetCalendarLeaderboardTracks returns all track calendar entries joined with their track names, sorted chronologically.
|
||||
func (s *PgStore) GetCalendarLeaderboardTracks(ctx context.Context) ([]TrackCalendarInfo, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT tc.id, tc.track_id, t.name, tc.start_at, tc.end_at
|
||||
FROM track_calendar tc
|
||||
JOIN tracks t ON tc.track_id = t.id
|
||||
ORDER BY tc.start_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query calendar tracks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []TrackCalendarInfo
|
||||
for rows.Next() {
|
||||
var info TrackCalendarInfo
|
||||
if err := rows.Scan(&info.ID, &info.TrackID, &info.TrackName, &info.StartAt, &info.EndAt); err != nil {
|
||||
return nil, fmt.Errorf("scan calendar track: %w", err)
|
||||
}
|
||||
out = append(out, info)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
package races
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestLeaderboard(t *testing.T) {
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL is not set, skipping database integration tests")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to test database: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
store := NewPgStore(pool)
|
||||
|
||||
// Clean up any left-overs with our test prefix
|
||||
cleanup := func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM race_drivers WHERE race_id LIKE 'test-race-%'")
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM races WHERE id LIKE 'test-race-%'")
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM drivers WHERE id LIKE 'test-driver-%'")
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM clans WHERE id LIKE 'test-clan-%'")
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
// 1. Create a test clan and test drivers
|
||||
nowMs := time.Now().UnixMilli()
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO clans (id, tag, name, avatar_url, created_ms, updated_ms)
|
||||
VALUES ('test-clan-1', 'TST', 'Test Clan', 'http://avatar.clan', $1, $1)`, nowMs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert test clan: %v", err)
|
||||
}
|
||||
|
||||
driversData := []struct {
|
||||
id string
|
||||
nick string
|
||||
name string
|
||||
}{
|
||||
{"test-driver-1", "DRV", "Driver One"},
|
||||
{"test-driver-2", "TWO", "Driver Two"},
|
||||
{"test-driver-3", "THR", "Driver Three"},
|
||||
{"test-driver-4", "FOR", "Driver Four"},
|
||||
}
|
||||
|
||||
for _, d := range driversData {
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO drivers (id, nickname, name, avatar_url, clan_id, created_ms, updated_ms)
|
||||
VALUES ($1, $2, $3, 'http://avatar', 'test-clan-1', $4, $4)`,
|
||||
d.id, d.nick, d.name, nowMs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert test driver %s: %v", d.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Create test races on different tracks for current year
|
||||
currentYear := time.Now().Year()
|
||||
testYearMs := time.Date(currentYear, time.June, 1, 12, 0, 0, 0, time.UTC).UnixMilli()
|
||||
|
||||
racesData := []struct {
|
||||
id string
|
||||
trackID string
|
||||
}{
|
||||
{"test-race-1", "test-track-A"},
|
||||
{"test-race-2", "test-track-A"},
|
||||
{"test-race-3", "test-track-B"},
|
||||
}
|
||||
|
||||
for _, r := range racesData {
|
||||
_, err = 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)
|
||||
VALUES ($1, 'Test Race', $2, 4, 10, 60, 'finished', $3, $3, $3, $3)`,
|
||||
r.id, r.trackID, testYearMs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert test race %s: %v", r.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Insert race drivers results
|
||||
// Race 1 (Track A):
|
||||
// - test-driver-1: best_lap 100 (1st fastest -> 25 points)
|
||||
// - test-driver-2: best_lap 400 (currently 3rd fastest -> 25 points)
|
||||
// - test-driver-3: best_lap 300 (currently 2nd fastest -> 25 points)
|
||||
// - test-driver-4: best_lap 0 (DNF, completed 0 laps -> 1 point)
|
||||
resultsRace1 := []struct {
|
||||
driverID string
|
||||
bestLapMs int64
|
||||
pos any
|
||||
}{
|
||||
{"test-driver-1", 100, 1},
|
||||
{"test-driver-2", 400, 2},
|
||||
{"test-driver-3", 300, 3},
|
||||
{"test-driver-4", 0, nil},
|
||||
}
|
||||
for _, res := range resultsRace1 {
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO race_drivers (race_id, driver_id, slot, joined_ms, total_time_ms, best_lap_ms, position)
|
||||
VALUES ('test-race-1', $1, 0, $2, 1000, $3, $4)`,
|
||||
res.driverID, testYearMs, res.bestLapMs, res.pos)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert race driver result: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Race 2 (Track A - testing best results):
|
||||
// - test-driver-2: best_lap 150 (2nd fastest -> 25 points, overrides 400ms lap time)
|
||||
// This makes the times:
|
||||
// - test-driver-1: 100ms (rank 1 -> 25 points)
|
||||
// - test-driver-2: 150ms (rank 2 -> 25 points)
|
||||
// - test-driver-3: 300ms (rank 3 -> 25 points)
|
||||
// - test-driver-4: NULL (rank 4 -> 1 point)
|
||||
// Exactly 3 drivers have 25 points.
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO race_drivers (race_id, driver_id, slot, joined_ms, total_time_ms, best_lap_ms, position)
|
||||
VALUES ('test-race-2', 'test-driver-2', 0, $1, 1000, 150, 1)`, testYearMs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert race 2 result: %v", err)
|
||||
}
|
||||
|
||||
// Race 3 (Track B):
|
||||
// - test-driver-1: best_lap 500 (rank 2 on Track B -> 25 points)
|
||||
// - test-driver-3: best_lap 200 (rank 1 on Track B -> 25 points)
|
||||
resultsRace3 := []struct {
|
||||
driverID string
|
||||
bestLapMs int64
|
||||
pos any
|
||||
}{
|
||||
{"test-driver-1", 500, 2},
|
||||
{"test-driver-3", 200, 1},
|
||||
}
|
||||
for _, res := range resultsRace3 {
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO race_drivers (race_id, driver_id, slot, joined_ms, total_time_ms, best_lap_ms, position)
|
||||
VALUES ('test-race-3', $1, 0, $2, 1000, $3, $4)`,
|
||||
res.driverID, testYearMs, res.bestLapMs, res.pos)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert race 3 driver result: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test Track A Leaderboard
|
||||
t.Run("Track A Leaderboard", func(t *testing.T) {
|
||||
entries, total, err := store.GetTrackLeaderboard(ctx, "test-track-A", currentYear, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get track leaderboard: %v", err)
|
||||
}
|
||||
if total != 4 {
|
||||
t.Errorf("expected total 4, got %d", total)
|
||||
}
|
||||
if len(entries) != 4 {
|
||||
t.Fatalf("expected 4 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
// Rank 1: test-driver-1 (25 pts, best_time 100ms)
|
||||
// Rank 2: test-driver-2 (25 pts, best_time 150ms)
|
||||
// Rank 3: test-driver-3 (25 pts, best_time 300ms)
|
||||
// Rank 4: test-driver-4 (1 pt, best_time NULL)
|
||||
drv1 := entries[0]
|
||||
if drv1.DriverID != "test-driver-1" || drv1.Points != 25 || *drv1.BestTimeMs != 100 || drv1.Rank != 1 {
|
||||
t.Errorf("invalid rank 1: %+v", drv1)
|
||||
}
|
||||
|
||||
drv2 := entries[1]
|
||||
if drv2.DriverID != "test-driver-2" || drv2.Points != 25 || *drv2.BestTimeMs != 150 || drv2.Rank != 2 {
|
||||
t.Errorf("invalid rank 2: %+v", drv2)
|
||||
}
|
||||
|
||||
// Check entries by looping to find specific drivers
|
||||
for _, e := range entries {
|
||||
if e.DriverID == "test-driver-3" {
|
||||
if e.Points != 25 || *e.BestTimeMs != 300 || e.Rank != 3 {
|
||||
t.Errorf("invalid test-driver-3: %+v", e)
|
||||
}
|
||||
}
|
||||
if e.DriverID == "test-driver-4" {
|
||||
if e.Points != 1 || e.BestTimeMs != nil || e.Rank != 4 {
|
||||
t.Errorf("invalid test-driver-4: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test Track A Driver Rank
|
||||
t.Run("Track A Driver Rank", func(t *testing.T) {
|
||||
entry, err := store.GetTrackLeaderboardDriver(ctx, "test-track-A", currentYear, "test-driver-2")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get driver rank: %v", err)
|
||||
}
|
||||
if entry == nil || entry.Rank != 2 || entry.Points != 25 || *entry.BestTimeMs != 150 {
|
||||
t.Errorf("expected rank 2, points 25, best_time 150, got %+v", entry)
|
||||
}
|
||||
})
|
||||
|
||||
// Test Overall Leaderboard
|
||||
t.Run("Overall Leaderboard", func(t *testing.T) {
|
||||
// Overall points calculation:
|
||||
// - test-driver-1: Track A (25) + Track B (25) = 50 points
|
||||
// - test-driver-3: Track A (25) + Track B (25) = 50 points
|
||||
// - test-driver-2: Track A (25) + Track B (0) = 25 points
|
||||
// - test-driver-4: Track A (1) + Track B (0) = 1 point
|
||||
entries, _, err := store.GetOverallLeaderboard(ctx, currentYear, 200, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get overall leaderboard: %v", err)
|
||||
}
|
||||
|
||||
// Filter to only our test drivers
|
||||
var testEntries []LeaderboardEntry
|
||||
for _, entry := range entries {
|
||||
if len(entry.DriverID) >= 11 && entry.DriverID[:12] == "test-driver-" {
|
||||
testEntries = append(testEntries, entry)
|
||||
}
|
||||
}
|
||||
|
||||
if len(testEntries) != 4 {
|
||||
t.Fatalf("expected 4 test entries, got %d", len(testEntries))
|
||||
}
|
||||
|
||||
// test-driver-1 and test-driver-3 should both have 50 points
|
||||
if testEntries[0].Points != 50 || testEntries[1].Points != 50 {
|
||||
t.Errorf("expected top 2 test drivers to have 50 points, got: %+v and %+v", testEntries[0], testEntries[1])
|
||||
}
|
||||
if testEntries[2].DriverID != "test-driver-2" || testEntries[2].Points != 25 {
|
||||
t.Errorf("invalid overall rank 3: %+v", testEntries[2])
|
||||
}
|
||||
if testEntries[3].DriverID != "test-driver-4" || testEntries[3].Points != 1 {
|
||||
t.Errorf("invalid overall rank 4: %+v", testEntries[3])
|
||||
}
|
||||
})
|
||||
|
||||
// Test Overall Driver Rank
|
||||
t.Run("Overall Driver Rank", func(t *testing.T) {
|
||||
entry, err := store.GetOverallLeaderboardDriver(ctx, currentYear, "test-driver-3")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get overall driver rank: %v", err)
|
||||
}
|
||||
if entry == nil || entry.Points != 50 || entry.Rank <= 0 {
|
||||
t.Errorf("expected rank > 0, points 50, got %+v", entry)
|
||||
}
|
||||
})
|
||||
|
||||
// Test Fallback Profile
|
||||
t.Run("Fallback Profile", func(t *testing.T) {
|
||||
entry, err := store.GetDriverLeaderboardProfile(ctx, "test-driver-1")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get profile: %v", err)
|
||||
}
|
||||
if entry == nil || entry.Name != "Driver One" || entry.Nickname != "DRV" || entry.ClanTag != "TST" {
|
||||
t.Errorf("expected Driver One, DRV, TST, got %+v", entry)
|
||||
}
|
||||
})
|
||||
|
||||
// Test Calendar Leaderboard
|
||||
t.Run("Calendar Leaderboard", func(t *testing.T) {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM track_calendar")
|
||||
|
||||
now := time.Now()
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO track_calendar (track_id, start_at, end_at) VALUES
|
||||
('monaco', $1, $2),
|
||||
('barcelona-catalunya', $3, $4),
|
||||
('austria-redbull-ring', $5, $6)
|
||||
`,
|
||||
now.Add(-10*time.Hour), now.Add(-5*time.Hour),
|
||||
now.Add(-2*time.Hour), now.Add(2*time.Hour),
|
||||
now.Add(5*time.Hour), now.Add(10*time.Hour),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert track calendar: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM track_calendar")
|
||||
}()
|
||||
|
||||
svc := NewService(store, nil)
|
||||
svc.now = func() time.Time { return now }
|
||||
|
||||
res, err := svc.GetCalendarLeaderboard(ctx, currentYear, "test-driver-1")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get calendar leaderboard: %v", err)
|
||||
}
|
||||
|
||||
if len(res) != 3 {
|
||||
t.Fatalf("expected 3 calendar entries, got %d", len(res))
|
||||
}
|
||||
|
||||
// 1. Austria (planned)
|
||||
if res[0].TrackID != "austria-redbull-ring" || res[0].Status != "planned" {
|
||||
t.Errorf("expected austria-redbull-ring (planned), got %s (%s)", res[0].TrackID, res[0].Status)
|
||||
}
|
||||
// 2. Barcelona (active)
|
||||
if res[1].TrackID != "barcelona-catalunya" || res[1].Status != "active" {
|
||||
t.Errorf("expected barcelona-catalunya (active), got %s (%s)", res[1].TrackID, res[1].Status)
|
||||
}
|
||||
// 3. Monaco (finished)
|
||||
if res[2].TrackID != "monaco" || res[2].Status != "finished" {
|
||||
t.Errorf("expected monaco (finished), got %s (%s)", res[2].TrackID, res[2].Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetRaceAndLastFinished(t *testing.T) {
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL is not set, skipping database integration tests")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to test database: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
store := NewPgStore(pool)
|
||||
|
||||
cleanup := func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM race_drivers WHERE race_id LIKE 'test-get-race-%'")
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM races WHERE id LIKE 'test-get-race-%'")
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
// 1. Insert a finished race
|
||||
now := time.Now().UnixMilli()
|
||||
race := FinishedRace{
|
||||
ID: "test-get-race-1",
|
||||
Name: "Test Get Race 1",
|
||||
TrackID: "monaco",
|
||||
MaxCars: 4,
|
||||
Laps: 5,
|
||||
TimeLimitS: 300,
|
||||
Status: "finished",
|
||||
CreatedMs: now - 10000,
|
||||
StartedMs: now - 8000,
|
||||
FinishedMs: now - 5000,
|
||||
DurationMs: 3000,
|
||||
TotalLaps: 20,
|
||||
TotalDrivers: 1,
|
||||
}
|
||||
err = store.InsertFinished(ctx, race)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert finished race: %v", err)
|
||||
}
|
||||
|
||||
// 2. Fetch the race by ID
|
||||
got, err := store.GetFinished(ctx, "test-get-race-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetFinished failed: %v", err)
|
||||
}
|
||||
if got.ID != "test-get-race-1" || got.Name != "Test Get Race 1" {
|
||||
t.Errorf("expected test-get-race-1, got: %+v", got)
|
||||
}
|
||||
|
||||
// 3. Insert a second finished race (newer finished_ms)
|
||||
race2 := FinishedRace{
|
||||
ID: "test-get-race-2",
|
||||
Name: "Test Get Race 2",
|
||||
TrackID: "monaco",
|
||||
MaxCars: 4,
|
||||
Laps: 5,
|
||||
TimeLimitS: 300,
|
||||
Status: "finished",
|
||||
CreatedMs: now - 5000,
|
||||
StartedMs: now - 4000,
|
||||
FinishedMs: now - 1000,
|
||||
DurationMs: 3000,
|
||||
TotalLaps: 20,
|
||||
TotalDrivers: 1,
|
||||
}
|
||||
err = store.InsertFinished(ctx, race2)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert finished race 2: %v", err)
|
||||
}
|
||||
|
||||
// 4. Fetch the last finished race (should be test-get-race-2)
|
||||
last, err := store.GetLastFinished(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLastFinished failed: %v", err)
|
||||
}
|
||||
if last.ID != "test-get-race-2" {
|
||||
t.Errorf("expected test-get-race-2 as last race, got: %s", last.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriverProfileStats(t *testing.T) {
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL is empty; skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open pool: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
store := NewPgStore(pool)
|
||||
|
||||
// Clean up any test records
|
||||
_, _ = store.Exec(ctx, `DELETE FROM race_queue WHERE driver_id = 'test-stats-drv'`)
|
||||
_, _ = store.Exec(ctx, `DELETE FROM race_drivers WHERE driver_id = 'test-stats-drv'`)
|
||||
_, _ = store.Exec(ctx, `DELETE FROM races WHERE id LIKE 'test-stats-race-%'`)
|
||||
_, _ = store.Exec(ctx, `DELETE FROM drivers WHERE id = 'test-stats-drv'`)
|
||||
|
||||
// 1. Create a driver
|
||||
_, _ = store.Exec(ctx, `
|
||||
INSERT INTO drivers (id, nickname, name, avatar_url, created_ms, updated_ms)
|
||||
VALUES ('test-stats-drv', 'TSD', 'Test Stats Driver', '', 1700000000, 1700000000)`)
|
||||
|
||||
// 2. Insert races and race results
|
||||
// Race 1: finished, TSD took 2nd position, best lap 15000, total time 50000
|
||||
race1 := FinishedRace{
|
||||
ID: "test-stats-race-1",
|
||||
Name: "Race 1",
|
||||
TrackID: "monaco",
|
||||
MaxCars: 4,
|
||||
Laps: 5,
|
||||
TimeLimitS: 300,
|
||||
DriverIDs: []string{"test-stats-drv"},
|
||||
Status: "finished",
|
||||
CreatedMs: 1700000000,
|
||||
StartedMs: 1700000500,
|
||||
FinishedMs: 1700060000,
|
||||
DurationMs: 59500,
|
||||
TotalLaps: 5,
|
||||
TotalDrivers: 2,
|
||||
Results: []DriverResult{
|
||||
{
|
||||
DriverID: "test-stats-drv",
|
||||
TotalTimeMs: 50000,
|
||||
BestLapMs: 15000,
|
||||
Position: ptrInt(2),
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := store.InsertFinished(ctx, race1); err != nil {
|
||||
t.Fatalf("InsertFinished 1 failed: %v", err)
|
||||
}
|
||||
|
||||
// Race 2: finished, TSD took 1st position (Win), best lap 14000 (Best Lap), total time 45000
|
||||
race2 := FinishedRace{
|
||||
ID: "test-stats-race-2",
|
||||
Name: "Race 2",
|
||||
TrackID: "monaco",
|
||||
MaxCars: 4,
|
||||
Laps: 5,
|
||||
TimeLimitS: 300,
|
||||
DriverIDs: []string{"test-stats-drv"},
|
||||
Status: "finished",
|
||||
CreatedMs: 1700100000,
|
||||
StartedMs: 1700100500,
|
||||
FinishedMs: 1700160000,
|
||||
DurationMs: 59500,
|
||||
TotalLaps: 5,
|
||||
TotalDrivers: 2,
|
||||
Results: []DriverResult{
|
||||
{
|
||||
DriverID: "test-stats-drv",
|
||||
TotalTimeMs: 45000,
|
||||
BestLapMs: 14000,
|
||||
Position: ptrInt(1),
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := store.InsertFinished(ctx, race2); err != nil {
|
||||
t.Fatalf("InsertFinished 2 failed: %v", err)
|
||||
}
|
||||
|
||||
// 3. Query stats
|
||||
stats, err := store.GetDriverStats(ctx, "test-stats-drv")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDriverStats failed: %v", err)
|
||||
}
|
||||
if stats.TotalRacesStarted != 2 || stats.TotalRacesFinished != 2 {
|
||||
t.Errorf("expected 2 races started/finished, got: %+v", stats)
|
||||
}
|
||||
if stats.Wins != 1 || stats.Podiums != 2 {
|
||||
t.Errorf("expected 1 win, 2 podiums, got: %+v", stats)
|
||||
}
|
||||
if stats.BestLapMs != 14000 {
|
||||
t.Errorf("expected best lap 14000, got %d", stats.BestLapMs)
|
||||
}
|
||||
if stats.TotalPlaytimeMs != 95000 {
|
||||
t.Errorf("expected total playtime 95000, got %d", stats.TotalPlaytimeMs)
|
||||
}
|
||||
|
||||
// 4. Query last race (should be Race 2, finished at 1700160000)
|
||||
last, err := store.GetDriverLastRace(ctx, "test-stats-drv")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDriverLastRace failed: %v", err)
|
||||
}
|
||||
if last == nil || last.RaceID != "test-stats-race-2" {
|
||||
t.Errorf("expected last race test-stats-race-2, got: %+v", last)
|
||||
}
|
||||
|
||||
// 5. Query best race (should be Race 2, because position 1 is better than 2)
|
||||
best, err := store.GetDriverBestRace(ctx, "test-stats-drv")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDriverBestRace failed: %v", err)
|
||||
}
|
||||
if best == nil || best.RaceID != "test-stats-race-2" {
|
||||
t.Errorf("expected best race test-stats-race-2, got: %+v", best)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrInt(v int) *int { return &v }
|
||||
|
||||
@@ -29,20 +29,35 @@ var DefaultNow = time.Now
|
||||
// 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
|
||||
id string
|
||||
nick string
|
||||
name string
|
||||
avatarURL string
|
||||
clanID string
|
||||
createdMs int64
|
||||
updatedMs int64
|
||||
}{
|
||||
{"ACE", "Alice"},
|
||||
{"BOB", "Bob"},
|
||||
{"CAR", "Carol"},
|
||||
{"DAV", "Dave"},
|
||||
{"EVE", "Eve"},
|
||||
{"FRA", "Frank"},
|
||||
{"GRA", "Grace"},
|
||||
{"HEI", "Heidi"},
|
||||
{"driver-seed-001", "BOR", "Gabriel Bortoleto", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/gabriel-bortoleto-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584003},
|
||||
{"driver-seed-002", "HUL", "Nico Hulkenberg", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/nico-hulkenberg-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584007},
|
||||
{"driver-seed-003", "ALB", "Alex Albon", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/alex-albon-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584008},
|
||||
{"driver-seed-004", "SAI", "Carlos Sainz", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/carlos-sainz-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584011},
|
||||
{"driver-seed-005", "BEA", "Oliver Bearman", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/oliver-bearman-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584012},
|
||||
{"driver-seed-006", "OCO", "Esteban Ocon", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/esteban-ocon-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584014},
|
||||
{"driver-seed-007", "LIN", "Arvid Lindblad", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/arvid-lindblad-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584018},
|
||||
{"driver-seed-008", "LAW", "Liam Lawson", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/liam-lawson-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584018},
|
||||
{"driver-seed-009", "COL", "Franco Colapinto", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/franco-colapinto-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584020},
|
||||
{"driver-seed-010", "GAS", "Pierre Gasly", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/pierre-gasly-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584023},
|
||||
{"driver-seed-011", "STR", "Lance Stroll", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/lance-stroll-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584023},
|
||||
{"driver-seed-012", "ALO", "Fernando Alonso", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/fernando-alonso-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584026},
|
||||
{"driver-seed-013", "ANT", "Kimi Antonelli", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/kimi-antonelli-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584028},
|
||||
{"driver-seed-014", "RUS", "George Russel", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/george-russell-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584028},
|
||||
{"driver-seed-015", "HAD", "Isack Hadjar", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/isack-hadjar-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584031},
|
||||
{"driver-seed-016", "VER", "Max Verstappen", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/max-verstappen-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584033},
|
||||
{"driver-seed-017", "HAM", "Lewis Hamilton", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/lewis-hamilton-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584034},
|
||||
{"driver-seed-018", "NOR", "Lando Norris", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/lando-norris-f1-driver-profile-picture.webp", "clan-seed-003", 1784120584003, 1784120584036},
|
||||
{"driver-seed-019", "LEC", "Charles Leclerc", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/charles-leclerc-f1-driver-profile-picture.webp", "clan-seed-001", 1784120584003, 1784120584038},
|
||||
{"driver-seed-020", "PIA", "Oscar Piastri", "https://supabasekong.ubu.is1di.ru/storage/v1/object/public/static/avatar/oscar-piastri-f1-driver-profile-picture.webp", "clan-seed-002", 1784120584003, 1784120584039},
|
||||
}
|
||||
|
||||
// Default clans.
|
||||
@@ -86,8 +101,8 @@ type Runner struct {
|
||||
|
||||
// Filled by loadInputs.
|
||||
trackIDs []string
|
||||
driverIDs []string // driver ids in the order they exist after seed
|
||||
driverInfo []seedDriver // id, nick, name, clanID, clanTag
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -211,11 +226,11 @@ func (r *Runner) Run(ctx context.Context, opt Options) (Summary, error) {
|
||||
|
||||
// 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
|
||||
{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]
|
||||
@@ -226,10 +241,54 @@ func (r *Runner) Run(ctx context.Context, opt Options) (Summary, error) {
|
||||
s.PlansInserted++
|
||||
}
|
||||
|
||||
if err := r.seedTrackCalendar(ctx); err != nil {
|
||||
return s, fmt.Errorf("seed track calendar: %w", err)
|
||||
}
|
||||
|
||||
s.Duration = time.Since(start)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *Runner) seedTrackCalendar(ctx context.Context) error {
|
||||
now := r.now
|
||||
|
||||
if len(r.trackIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tracksMap := make(map[string]bool)
|
||||
for _, id := range r.trackIDs {
|
||||
tracksMap[id] = true
|
||||
}
|
||||
|
||||
schedule := []struct {
|
||||
trackID string
|
||||
startAt time.Time
|
||||
endAt time.Time
|
||||
}{
|
||||
{"monaco", now.Add(-14 * 24 * time.Hour), now.Add(-7 * 24 * time.Hour)},
|
||||
{"barcelona-catalunya", now.Add(-2 * 24 * time.Hour), now.Add(12 * 24 * time.Hour)},
|
||||
{"austria-redbull-ring", now.Add(14 * 24 * time.Hour), now.Add(28 * 24 * time.Hour)},
|
||||
{"great-britain-silverstone", now.Add(28 * 24 * time.Hour), now.Add(42 * 24 * time.Hour)},
|
||||
{"belgium-spa", now.Add(42 * 24 * time.Hour), now.Add(56 * 24 * time.Hour)},
|
||||
}
|
||||
|
||||
for _, s := range schedule {
|
||||
if !tracksMap[s.trackID] {
|
||||
continue
|
||||
}
|
||||
_, err := r.pg.Exec(ctx, `
|
||||
INSERT INTO track_calendar (track_id, start_at, end_at)
|
||||
VALUES ($1, $2, $3)
|
||||
`, s.trackID, s.startAt, s.endAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert calendar entry: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type planShape struct {
|
||||
intervalS int
|
||||
count int
|
||||
@@ -241,18 +300,31 @@ type planShape struct {
|
||||
// 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 {
|
||||
if _, err := r.pg.Exec(ctx, `DELETE FROM race_queue`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx,
|
||||
`TRUNCATE TABLE race_plans, race_queue`); err != nil {
|
||||
if _, err := r.pg.Exec(ctx, `DELETE FROM races`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx, `DELETE FROM race_plans`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx, `DELETE FROM track_calendar`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx, `DELETE FROM drivers`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx, `DELETE FROM clans`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx, `ALTER SEQUENCE IF EXISTS track_calendar_id_seq RESTART WITH 1`); 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 {
|
||||
if _, err := r.pg.Exec(ctx, `DELETE FROM lobby_drivers`); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -286,21 +358,15 @@ func (r *Runner) seedDriversAndClans(ctx context.Context) error {
|
||||
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 {
|
||||
for _, ds := range defaultDriverSeeds {
|
||||
d := drivers.Driver{
|
||||
ID: fmt.Sprintf("driver-seed-%03d", i+1),
|
||||
ID: ds.id,
|
||||
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,
|
||||
AvatarURL: ds.avatarURL,
|
||||
ClanID: ds.clanID,
|
||||
CreatedMs: ds.createdMs,
|
||||
UpdatedMs: ds.updatedMs,
|
||||
}
|
||||
if err := r.driversPg.Create(ctx, d); err != nil {
|
||||
return fmt.Errorf("insert driver %s: %w", ds.nick, err)
|
||||
@@ -481,8 +547,15 @@ func (r *Runner) createLive(status lobby.RaceStatus, idx int) (lobby.RaceMeta, e
|
||||
continue
|
||||
}
|
||||
r.lb.SetDriverProfile(d.ID, d.Nickname, "", d.ClanID, d.ClanTag)
|
||||
devID := i
|
||||
_ = r.lb.SelectDevice(d.ID, &devID)
|
||||
cars := []string{
|
||||
"f1-2024-redbull-rb20",
|
||||
"f1-2024-ferrari-sf24",
|
||||
"f1-2024-mclaren-mcl38",
|
||||
"f1-2024-mercedes-w15",
|
||||
"f1-2024-astonmartin-amr24",
|
||||
}
|
||||
carID := cars[i%len(cars)]
|
||||
_ = r.lb.SelectCar(d.ID, &carID)
|
||||
if err := r.lb.AddDriverToRace(d.ID, meta.ID); err != nil {
|
||||
// ignore: race may be at capacity
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ func (s *Service) RestoreFromDB(ctx context.Context) (int, int, error) {
|
||||
// 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)
|
||||
if d.DeviceID != nil {
|
||||
_ = s.lobby.SelectDevice(d.ID, d.DeviceID)
|
||||
if d.CarID != nil {
|
||||
_ = s.lobby.SelectCar(d.ID, d.CarID)
|
||||
}
|
||||
}
|
||||
for _, r := range races {
|
||||
@@ -685,6 +685,175 @@ func (s *Service) DeletePlan(ctx context.Context, id string) error {
|
||||
return s.pg.DeletePlan(ctx, id)
|
||||
}
|
||||
|
||||
// GetLeaderboard retrieves the leaderboard page and current driver position.
|
||||
func (s *Service) GetLeaderboard(ctx context.Context, trackID string, year int, limit, offset int, currentDriverID string) (*LeaderboardResult, error) {
|
||||
if year == 0 {
|
||||
year = s.now().Year()
|
||||
}
|
||||
|
||||
var items []LeaderboardEntry
|
||||
var total int
|
||||
var err error
|
||||
|
||||
if trackID != "" {
|
||||
items, total, err = s.pg.GetTrackLeaderboard(ctx, trackID, year, limit, offset)
|
||||
} else {
|
||||
items, total, err = s.pg.GetOverallLeaderboard(ctx, year, limit, offset)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var currentDriver *LeaderboardEntry
|
||||
if currentDriverID != "" {
|
||||
if trackID != "" {
|
||||
currentDriver, err = s.pg.GetTrackLeaderboardDriver(ctx, trackID, year, currentDriverID)
|
||||
} else {
|
||||
currentDriver, err = s.pg.GetOverallLeaderboardDriver(ctx, year, currentDriverID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if currentDriver == nil {
|
||||
// fallback: get profile from drivers table
|
||||
currentDriver, err = s.pg.GetDriverLeaderboardProfile(ctx, currentDriverID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// if driver exists but has no points, set points/rank to 0
|
||||
if currentDriver != nil {
|
||||
currentDriver.Points = 0
|
||||
currentDriver.Rank = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if items == nil {
|
||||
items = []LeaderboardEntry{}
|
||||
}
|
||||
|
||||
return &LeaderboardResult{
|
||||
Items: items,
|
||||
Total: total,
|
||||
CurrentDriver: currentDriver,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCalendarLeaderboard returns the leaderboard details of all scheduled tracks.
|
||||
func (s *Service) GetCalendarLeaderboard(ctx context.Context, year int, currentDriverID string) ([]TrackCalendarLeaderboard, error) {
|
||||
if year == 0 {
|
||||
year = s.now().Year()
|
||||
}
|
||||
|
||||
calendarInfo, err := s.pg.GetCalendarLeaderboardTracks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := s.now()
|
||||
var out []TrackCalendarLeaderboard
|
||||
|
||||
// Cache driver profile fallback if we need it
|
||||
var fallbackProfile *LeaderboardEntry
|
||||
if currentDriverID != "" {
|
||||
fallbackProfile, err = s.pg.GetDriverLeaderboardProfile(ctx, currentDriverID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fallbackProfile != nil {
|
||||
fallbackProfile.Points = 0
|
||||
fallbackProfile.Rank = 0
|
||||
}
|
||||
}
|
||||
|
||||
for _, cal := range calendarInfo {
|
||||
status := "planned"
|
||||
if now.After(cal.EndAt) {
|
||||
status = "finished"
|
||||
} else if (now.Equal(cal.StartAt) || now.After(cal.StartAt)) && now.Before(cal.EndAt) {
|
||||
status = "active"
|
||||
}
|
||||
|
||||
// Get top 3
|
||||
podium, _, err := s.pg.GetTrackLeaderboard(ctx, cal.TrackID, year, 3, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if podium == nil {
|
||||
podium = []LeaderboardEntry{}
|
||||
}
|
||||
|
||||
// Get current driver
|
||||
var curDriver *LeaderboardEntry
|
||||
if currentDriverID != "" {
|
||||
curDriver, err = s.pg.GetTrackLeaderboardDriver(ctx, cal.TrackID, year, currentDriverID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if curDriver == nil && fallbackProfile != nil {
|
||||
// Clone the fallback profile so we don't share pointer/modify original
|
||||
profileCopy := *fallbackProfile
|
||||
curDriver = &profileCopy
|
||||
}
|
||||
}
|
||||
|
||||
out = append(out, TrackCalendarLeaderboard{
|
||||
TrackID: cal.TrackID,
|
||||
TrackName: cal.TrackName,
|
||||
StartAt: cal.StartAt,
|
||||
EndAt: cal.EndAt,
|
||||
Status: status,
|
||||
Podium: podium,
|
||||
CurrentDriver: curDriver,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetRace fetches a single race by id (either live from lobby or finished from DB).
|
||||
func (s *Service) GetRace(ctx context.Context, id string) (FinishedRace, error) {
|
||||
meta, err := s.lobby.GetRace(id)
|
||||
if err == nil {
|
||||
return FinishedRace{
|
||||
ID: meta.ID,
|
||||
Name: meta.Name,
|
||||
TrackID: meta.TrackID,
|
||||
MaxCars: meta.MaxCars,
|
||||
Laps: meta.Laps,
|
||||
TimeLimitS: meta.TimeLimitS,
|
||||
DriverIDs: meta.DriverIDs,
|
||||
Status: string(meta.Status),
|
||||
CreatedMs: meta.CreatedMs,
|
||||
StartedMs: meta.StartedMs,
|
||||
}, nil
|
||||
}
|
||||
return s.pg.GetFinished(ctx, id)
|
||||
}
|
||||
|
||||
// GetLastFinished fetches the most recently finished race.
|
||||
func (s *Service) GetLastFinished(ctx context.Context) (FinishedRace, error) {
|
||||
return s.pg.GetLastFinished(ctx)
|
||||
}
|
||||
|
||||
// GetDriverProfileStats returns the driver stats and summaries of their last and best races.
|
||||
func (s *Service) GetDriverProfileStats(ctx context.Context, driverID string) (DriverStats, *DriverRaceSummary, *DriverRaceSummary, error) {
|
||||
stats, err := s.pg.GetDriverStats(ctx, driverID)
|
||||
if err != nil {
|
||||
return DriverStats{}, nil, nil, err
|
||||
}
|
||||
last, err := s.pg.GetDriverLastRace(ctx, driverID)
|
||||
if err != nil {
|
||||
return DriverStats{}, nil, nil, err
|
||||
}
|
||||
best, err := s.pg.GetDriverBestRace(ctx, driverID)
|
||||
if err != nil {
|
||||
return DriverStats{}, nil, nil, err
|
||||
}
|
||||
return stats, last, best, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -112,8 +112,8 @@ func NewPgStore(pool *pgxpool.Pool) *PgStore {
|
||||
}
|
||||
|
||||
// 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)
|
||||
func (s *PgStore) Exec(ctx context.Context, sql string, args ...any) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -391,7 +391,7 @@ func (s *PgStore) UpsertLobbyDriver(ctx context.Context, d lobby.DriverMeta) err
|
||||
_, 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, device_id)
|
||||
status, current_race_id, connected_ms, last_seen_ms, car_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (driver_id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
@@ -402,9 +402,9 @@ func (s *PgStore) UpsertLobbyDriver(ctx context.Context, d lobby.DriverMeta) err
|
||||
status = EXCLUDED.status,
|
||||
current_race_id = EXCLUDED.current_race_id,
|
||||
last_seen_ms = EXCLUDED.last_seen_ms,
|
||||
device_id = EXCLUDED.device_id`,
|
||||
car_id = EXCLUDED.car_id`,
|
||||
d.ID, d.Name, d.Nickname, d.AvatarURL, d.ClanID, d.ClanTag,
|
||||
string(d.Status), d.RaceID, d.ConnectedMs, d.LastSeenMs, d.DeviceID)
|
||||
string(d.Status), d.RaceID, d.ConnectedMs, d.LastSeenMs, d.CarID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert lobby driver %s: %w", d.ID, err)
|
||||
}
|
||||
@@ -421,7 +421,7 @@ func (s *PgStore) DeleteLobbyDriver(ctx context.Context, driverID string) error
|
||||
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, device_id
|
||||
status, current_race_id, connected_ms, last_seen_ms, car_id
|
||||
FROM lobby_drivers ORDER BY last_seen_ms DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -432,7 +432,7 @@ func (s *PgStore) ListLobbyDrivers(ctx context.Context) ([]lobby.DriverMeta, err
|
||||
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, &d.DeviceID); err != nil {
|
||||
&status, &d.RaceID, &d.ConnectedMs, &d.LastSeenMs, &d.CarID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Status = lobby.DriverStatus(status)
|
||||
@@ -696,6 +696,61 @@ func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFinished fetches a single finished race by id.
|
||||
func (s *PgStore) GetFinished(ctx context.Context, id string) (FinishedRace, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, name, track_id, max_cars, laps, time_limit_s,
|
||||
status, created_ms, started_ms, finished_ms, duration_ms,
|
||||
total_laps, total_drivers
|
||||
FROM races
|
||||
WHERE id = $1 AND status IN ('finished', 'cancelled')`, id)
|
||||
var r FinishedRace
|
||||
if err := row.Scan(
|
||||
&r.ID, &r.Name, &r.TrackID,
|
||||
&r.MaxCars, &r.Laps, &r.TimeLimitS, &r.Status,
|
||||
&r.CreatedMs, &r.StartedMs, &r.FinishedMs, &r.DurationMs,
|
||||
&r.TotalLaps, &r.TotalDrivers,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return FinishedRace{}, ErrNotFound
|
||||
}
|
||||
return FinishedRace{}, err
|
||||
}
|
||||
if err := s.hydrateFinished(ctx, &r); err != nil {
|
||||
return FinishedRace{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// GetLastFinished fetches the most recently finished race.
|
||||
func (s *PgStore) GetLastFinished(ctx context.Context) (FinishedRace, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, name, track_id, max_cars, laps, time_limit_s,
|
||||
status, created_ms, started_ms, finished_ms, duration_ms,
|
||||
total_laps, total_drivers
|
||||
FROM races
|
||||
WHERE status IN ('finished', 'cancelled')
|
||||
ORDER BY finished_ms DESC, id DESC
|
||||
LIMIT 1`)
|
||||
var r FinishedRace
|
||||
if err := row.Scan(
|
||||
&r.ID, &r.Name, &r.TrackID,
|
||||
&r.MaxCars, &r.Laps, &r.TimeLimitS, &r.Status,
|
||||
&r.CreatedMs, &r.StartedMs, &r.FinishedMs, &r.DurationMs,
|
||||
&r.TotalLaps, &r.TotalDrivers,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return FinishedRace{}, ErrNotFound
|
||||
}
|
||||
return FinishedRace{}, err
|
||||
}
|
||||
if err := s.hydrateFinished(ctx, &r); err != nil {
|
||||
return FinishedRace{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
|
||||
func joinAnd(parts []string) string {
|
||||
out := ""
|
||||
for i, p := range parts {
|
||||
@@ -991,3 +1046,132 @@ func (f FinishedRace) ToLobbyMeta() lobby.RaceMeta {
|
||||
StartedMs: f.StartedMs,
|
||||
}
|
||||
}
|
||||
|
||||
// DriverStats holds database-calculated stats.
|
||||
type DriverStats struct {
|
||||
TotalRacesStarted int64
|
||||
TotalRacesFinished int64
|
||||
Wins int64
|
||||
Podiums int64
|
||||
BestLapMs int64
|
||||
TotalPlaytimeMs int64
|
||||
}
|
||||
|
||||
// DriverRaceSummary holds a summary of a single finished race.
|
||||
type DriverRaceSummary struct {
|
||||
RaceID string
|
||||
RaceName string
|
||||
FinishedMs int64
|
||||
Position int
|
||||
TotalTimeMs int64
|
||||
BestLapMs int64
|
||||
TrackID string
|
||||
TrackName string
|
||||
}
|
||||
|
||||
// GetDriverStats aggregates statistics for a given driver from finished races.
|
||||
func (s *PgStore) GetDriverStats(ctx context.Context, driverID string) (DriverStats, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
COUNT(*)::bigint as total_races_started,
|
||||
COUNT(CASE WHEN rd.position IS NOT NULL OR rd.total_time_ms > 0 THEN 1 END)::bigint as total_races_finished,
|
||||
COUNT(CASE WHEN rd.position = 1 THEN 1 END)::bigint as wins,
|
||||
COUNT(CASE WHEN rd.position >= 1 AND rd.position <= 3 THEN 1 END)::bigint as podiums,
|
||||
COALESCE(MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms END), 0)::bigint as best_lap_ms,
|
||||
COALESCE(SUM(rd.total_time_ms), 0)::bigint as total_playtime_ms
|
||||
FROM race_drivers rd
|
||||
JOIN races r ON rd.race_id = r.id
|
||||
WHERE rd.driver_id = $1 AND r.status = 'finished'`, driverID)
|
||||
|
||||
var stats DriverStats
|
||||
err := row.Scan(
|
||||
&stats.TotalRacesStarted,
|
||||
&stats.TotalRacesFinished,
|
||||
&stats.Wins,
|
||||
&stats.Podiums,
|
||||
&stats.BestLapMs,
|
||||
&stats.TotalPlaytimeMs,
|
||||
)
|
||||
if err != nil {
|
||||
return DriverStats{}, fmt.Errorf("query driver stats: %w", err)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetDriverLastRace returns the last completed race for a driver.
|
||||
func (s *PgStore) GetDriverLastRace(ctx context.Context, driverID string) (*DriverRaceSummary, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
rd.race_id,
|
||||
r.name,
|
||||
r.finished_ms,
|
||||
COALESCE(rd.position, 0) as position,
|
||||
rd.total_time_ms,
|
||||
rd.best_lap_ms,
|
||||
r.track_id,
|
||||
COALESCE(t.name, '') as track_name
|
||||
FROM race_drivers rd
|
||||
JOIN races r ON rd.race_id = r.id
|
||||
LEFT JOIN tracks t ON r.track_id = t.id
|
||||
WHERE rd.driver_id = $1 AND r.status = 'finished'
|
||||
ORDER BY r.finished_ms DESC, r.id DESC
|
||||
LIMIT 1`, driverID)
|
||||
|
||||
var sum DriverRaceSummary
|
||||
err := row.Scan(
|
||||
&sum.RaceID,
|
||||
&sum.RaceName,
|
||||
&sum.FinishedMs,
|
||||
&sum.Position,
|
||||
&sum.TotalTimeMs,
|
||||
&sum.BestLapMs,
|
||||
&sum.TrackID,
|
||||
&sum.TrackName,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("query driver last race: %w", err)
|
||||
}
|
||||
return &sum, nil
|
||||
}
|
||||
|
||||
// GetDriverBestRace returns the best completed race for a driver (by position, then time).
|
||||
func (s *PgStore) GetDriverBestRace(ctx context.Context, driverID string) (*DriverRaceSummary, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
rd.race_id,
|
||||
r.name,
|
||||
r.finished_ms,
|
||||
COALESCE(rd.position, 0) as position,
|
||||
rd.total_time_ms,
|
||||
rd.best_lap_ms,
|
||||
r.track_id,
|
||||
COALESCE(t.name, '') as track_name
|
||||
FROM race_drivers rd
|
||||
JOIN races r ON rd.race_id = r.id
|
||||
LEFT JOIN tracks t ON r.track_id = t.id
|
||||
WHERE rd.driver_id = $1 AND r.status = 'finished' AND rd.position IS NOT NULL AND rd.position > 0
|
||||
ORDER BY rd.position ASC, rd.total_time_ms ASC, r.finished_ms DESC, r.id DESC
|
||||
LIMIT 1`, driverID)
|
||||
|
||||
var sum DriverRaceSummary
|
||||
err := row.Scan(
|
||||
&sum.RaceID,
|
||||
&sum.RaceName,
|
||||
&sum.FinishedMs,
|
||||
&sum.Position,
|
||||
&sum.TotalTimeMs,
|
||||
&sum.BestLapMs,
|
||||
&sum.TrackID,
|
||||
&sum.TrackName,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("query driver best race: %w", err)
|
||||
}
|
||||
return &sum, nil
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
-- (S2). We enable it here so that the extension is provisioned alongside
|
||||
-- the base schema.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||
-- CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tracks
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -20,56 +20,56 @@ UPDATE tracks SET
|
||||
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);
|
||||
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,11 @@
|
||||
-- 014_track_calendar.sql — schema for F1-style active track scheduling.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS track_calendar (
|
||||
id SERIAL PRIMARY KEY,
|
||||
track_id TEXT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
start_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
end_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
CHECK (start_at < end_at)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS track_calendar_dates_idx ON track_calendar (start_at, end_at);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 015_driver_car_id.sql — move device_id to cars table and use car_id in lobby_drivers
|
||||
--
|
||||
-- Represents the currently selected physical ESP32 DEVICE_ID (0..127) for a car.
|
||||
-- And represents the currently selected car ID for a driver in lobby_drivers.
|
||||
|
||||
ALTER TABLE cars ADD COLUMN IF NOT EXISTS device_id INT;
|
||||
ALTER TABLE lobby_drivers ADD COLUMN IF NOT EXISTS car_id TEXT;
|
||||
ALTER TABLE lobby_drivers DROP COLUMN IF EXISTS device_id;
|
||||
@@ -142,7 +142,7 @@ func (s *PgStore) loadCars(ctx context.Context) ([]catalog.CarMeta, error) {
|
||||
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
|
||||
created_ms, updated_ms, device_id
|
||||
FROM cars
|
||||
ORDER BY id`)
|
||||
if err != nil {
|
||||
@@ -161,7 +161,7 @@ func (s *PgStore) loadCars(ctx context.Context) ([]catalog.CarMeta, error) {
|
||||
&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,
|
||||
&c.CreatedMs, &c.UpdatedMs, &c.DeviceID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -260,8 +260,8 @@ func (s *PgStore) UpsertCar(ctx context.Context, c catalog.CarMeta) error {
|
||||
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)
|
||||
created_ms, updated_ms, device_id)
|
||||
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,$33)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
owner_id = EXCLUDED.owner_id,
|
||||
@@ -293,7 +293,8 @@ func (s *PgStore) UpsertCar(ctx context.Context, c catalog.CarMeta) error {
|
||||
total_races = EXCLUDED.total_races,
|
||||
total_laps = EXCLUDED.total_laps,
|
||||
best_lap_ms = EXCLUDED.best_lap_ms,
|
||||
updated_ms = EXCLUDED.updated_ms`,
|
||||
updated_ms = EXCLUDED.updated_ms,
|
||||
device_id = EXCLUDED.device_id`,
|
||||
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,
|
||||
@@ -302,7 +303,7 @@ func (s *PgStore) UpsertCar(ctx context.Context, c catalog.CarMeta) error {
|
||||
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,
|
||||
c.CreatedMs, c.UpdatedMs, c.DeviceID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -333,7 +334,7 @@ func (s *PgStore) UpdateCarStats(ctx context.Context, id string, fn func(*catalo
|
||||
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
|
||||
created_ms, updated_ms, device_id
|
||||
FROM cars WHERE id = $1 FOR UPDATE`, id,
|
||||
).Scan(
|
||||
&c.ID, &c.Name, &c.OwnerID, &c.Visibility, &c.Scale,
|
||||
@@ -344,7 +345,7 @@ func (s *PgStore) UpdateCarStats(ctx context.Context, id string, fn func(*catalo
|
||||
&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,
|
||||
&c.CreatedMs, &c.UpdatedMs, &c.DeviceID,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -370,4 +371,52 @@ func (s *PgStore) UpdateCarStats(ctx context.Context, id string, fn func(*catalo
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// GetTrackCalendar returns all track calendar entries ordered by start_at.
|
||||
func (s *PgStore) GetTrackCalendar(ctx context.Context) ([]catalog.TrackCalendarEntry, error) {
|
||||
query := `SELECT id, track_id, start_at, end_at FROM track_calendar ORDER BY start_at ASC`
|
||||
rows, err := s.pool.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []catalog.TrackCalendarEntry
|
||||
for rows.Next() {
|
||||
var entry catalog.TrackCalendarEntry
|
||||
if err := rows.Scan(&entry.ID, &entry.TrackID, &entry.StartAt, &entry.EndAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// CreateTrackCalendar inserts a new calendar entry.
|
||||
func (s *PgStore) CreateTrackCalendar(ctx context.Context, entry catalog.TrackCalendarEntry) (catalog.TrackCalendarEntry, error) {
|
||||
query := `
|
||||
INSERT INTO track_calendar (track_id, start_at, end_at)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, track_id, start_at, end_at`
|
||||
|
||||
row := s.pool.QueryRow(ctx, query, entry.TrackID, entry.StartAt, entry.EndAt)
|
||||
var res catalog.TrackCalendarEntry
|
||||
if err := row.Scan(&res.ID, &res.TrackID, &res.StartAt, &res.EndAt); err != nil {
|
||||
return catalog.TrackCalendarEntry{}, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// DeleteTrackCalendar deletes a calendar entry by ID.
|
||||
func (s *PgStore) DeleteTrackCalendar(ctx context.Context, id int) error {
|
||||
command := `DELETE FROM track_calendar WHERE id = $1`
|
||||
res, err := s.pool.Exec(ctx, command, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.RowsAffected() == 0 {
|
||||
return catalog.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -239,6 +239,19 @@ type VersionResponse struct {
|
||||
Version string `json:"version" example:"0.1.0-poc"`
|
||||
}
|
||||
|
||||
// WebRTCConnectRequest is the body of the WebRTC connection API.
|
||||
type WebRTCConnectRequest struct {
|
||||
SDP string `json:"sdp"`
|
||||
Type string `json:"type" example:"offer"`
|
||||
DriverID string `json:"driver_id" example:"driver-alice"`
|
||||
}
|
||||
|
||||
// WebRTCConnectResponse is the response to WebRTCConnectRequest.
|
||||
type WebRTCConnectResponse struct {
|
||||
SDP string `json:"sdp"`
|
||||
Type string `json:"type" example:"answer"`
|
||||
}
|
||||
|
||||
// LobbyDriver is the wire form of lobby.DriverMeta.
|
||||
type LobbyDriver struct {
|
||||
ID string `json:"id"`
|
||||
@@ -249,7 +262,6 @@ type LobbyDriver struct {
|
||||
LastSeenMs int64 `json:"last_seen_ms"`
|
||||
Country string `json:"country,omitempty"`
|
||||
AvatarHue int `json:"avatar_hue,omitempty"`
|
||||
DeviceID *int `json:"device_id,omitempty"`
|
||||
}
|
||||
|
||||
// LobbySnapshot is the full lobby state, broadcast on change.
|
||||
@@ -350,6 +362,7 @@ type TrackWire struct {
|
||||
BestLapHolder string `json:"best_lap_holder,omitempty"`
|
||||
CreatedMs int64 `json:"created_ms"`
|
||||
UpdatedMs int64 `json:"updated_ms"`
|
||||
IsActive bool `json:"is_active" example:"true"`
|
||||
}
|
||||
|
||||
// TrackListRequest: payload { "scope": "all|system|public|private", "tag": "..." }.
|
||||
@@ -465,6 +478,7 @@ type CarWire struct {
|
||||
|
||||
CreatedMs int64 `json:"created_ms"`
|
||||
UpdatedMs int64 `json:"updated_ms"`
|
||||
DeviceID *int `json:"device_id,omitempty"`
|
||||
}
|
||||
|
||||
// CarListRequest: payload { "scope": "all|system|public|private", "owner_id": "..." }.
|
||||
@@ -506,6 +520,7 @@ type CarPatch struct {
|
||||
ColorHex *string `json:"color_hex,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
DeviceID *int `json:"device_id,omitempty"`
|
||||
}
|
||||
|
||||
// CarDeleteRequest: payload { "id": "..." }.
|
||||
@@ -659,11 +674,42 @@ type DriverCreateRequest struct {
|
||||
|
||||
// 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"`
|
||||
Nickname string `json:"nickname,omitempty" example:"ALI"`
|
||||
Name string `json:"name,omitempty" example:"Alice"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/u/ace.png"`
|
||||
ClanID *string `json:"clan_id,omitempty" example:"clan-1782000-1"`
|
||||
}
|
||||
|
||||
// DriverProfileResponse is the body returned by GET /api/drivers/{id}/profile.
|
||||
type DriverProfileResponse struct {
|
||||
Driver Driver `json:"driver"`
|
||||
Stats DriverStats `json:"stats"`
|
||||
LastRace *DriverRaceSummary `json:"last_race"`
|
||||
BestRace *DriverRaceSummary `json:"best_race"`
|
||||
}
|
||||
|
||||
// DriverStats contains aggregate driver statistics.
|
||||
type DriverStats struct {
|
||||
TotalRacesStarted int64 `json:"total_races_started" example:"10"`
|
||||
TotalRacesFinished int64 `json:"total_races_finished" example:"8"`
|
||||
Wins int64 `json:"wins" example:"2"`
|
||||
Podiums int64 `json:"podiums" example:"5"`
|
||||
BestLapMs int64 `json:"best_lap_ms" example:"12345"`
|
||||
TotalPlaytimeMs int64 `json:"total_playtime_ms" example:"1200000"`
|
||||
}
|
||||
|
||||
// DriverRaceSummary describes one race result in a driver's profile.
|
||||
type DriverRaceSummary struct {
|
||||
RaceID string `json:"race_id" example:"race-123"`
|
||||
RaceName string `json:"race_name" example:"Monaco Grand Prix"`
|
||||
FinishedMs int64 `json:"finished_ms" example:"1700000000000"`
|
||||
Position int `json:"position" example:"1"`
|
||||
TotalTimeMs int64 `json:"total_time_ms" example:"123456"`
|
||||
BestLapMs int64 `json:"best_lap_ms" example:"12345"`
|
||||
TrackID string `json:"track_id" example:"monaco"`
|
||||
TrackName string `json:"track_name" example:"Monaco"`
|
||||
}
|
||||
|
||||
// DriverListResponse is the body returned by GET /api/drivers.
|
||||
type DriverListResponse struct {
|
||||
Items []Driver `json:"items"`
|
||||
@@ -827,6 +873,65 @@ type RaceResultMsg struct {
|
||||
FinishedAtMs int64 `json:"finished_at_ms"`
|
||||
}
|
||||
|
||||
// LeaderboardEntry is a single row in the paginated leaderboard response.
|
||||
type LeaderboardEntry struct {
|
||||
DriverID string `json:"driver_id" example:"driver-seed-001"`
|
||||
Nickname string `json:"nickname" example:"ACE"`
|
||||
Name string `json:"name" example:"Alice"`
|
||||
AvatarURL string `json:"avatar_url" example:"https://cdn.example.com/u/ace.png"`
|
||||
ClanID string `json:"clan_id,omitempty" example:"clan-seed-001"`
|
||||
ClanTag string `json:"clan_tag,omitempty" example:"ACE"`
|
||||
Points int `json:"points" example:"25"`
|
||||
BestPos *int `json:"best_pos,omitempty" example:"1"` // Only populated for track-specific leaderboard
|
||||
BestTimeMs *int64 `json:"best_time_ms,omitempty" example:"15000"`
|
||||
Rank int `json:"rank" example:"1"`
|
||||
}
|
||||
|
||||
// LeaderboardResponse is the paginated response for GET /api/leaderboard.
|
||||
type LeaderboardResponse struct {
|
||||
Items []LeaderboardEntry `json:"items"`
|
||||
Total int `json:"total" example:"8"`
|
||||
Limit int `json:"limit" example:"50"`
|
||||
Offset int `json:"offset" example:"0"`
|
||||
CurrentDriver *LeaderboardEntry `json:"current_driver,omitempty"`
|
||||
}
|
||||
|
||||
// TrackCalendarEntry is the wire representation of an active track schedule period.
|
||||
type TrackCalendarEntry struct {
|
||||
ID int `json:"id" example:"1"`
|
||||
TrackID string `json:"track_id" example:"monaco"`
|
||||
StartAt time.Time `json:"start_at" example:"2026-07-15T00:00:00Z"`
|
||||
EndAt time.Time `json:"end_at" example:"2026-07-30T00:00:00Z"`
|
||||
}
|
||||
|
||||
// TrackCalendarCreateRequest is the payload to schedule a track active period.
|
||||
type TrackCalendarCreateRequest struct {
|
||||
TrackID string `json:"track_id" example:"monaco"`
|
||||
StartAt time.Time `json:"start_at" example:"2026-07-15T00:00:00Z"`
|
||||
EndAt time.Time `json:"end_at" example:"2026-07-30T00:00:00Z"`
|
||||
}
|
||||
|
||||
// TrackCalendarResponse is the wrapper for the calendar list.
|
||||
type TrackCalendarResponse struct {
|
||||
Items []TrackCalendarEntry `json:"items"`
|
||||
}
|
||||
|
||||
// TrackCalendarLeaderboard holds the leaderboard summary for a track calendar event.
|
||||
type TrackCalendarLeaderboard struct {
|
||||
TrackID string `json:"track_id" example:"monaco"`
|
||||
TrackName string `json:"track_name" example:"Monaco"`
|
||||
StartAt time.Time `json:"start_at" example:"2026-07-15T00:00:00Z"`
|
||||
EndAt time.Time `json:"end_at" example:"2026-07-30T00:00:00Z"`
|
||||
Status string `json:"status" example:"active"`
|
||||
Podium []LeaderboardEntry `json:"podium"`
|
||||
CurrentDriver *LeaderboardEntry `json:"current_driver,omitempty"`
|
||||
}
|
||||
|
||||
// TrackCalendarLeaderboardResponse is the list response for calendar leaderboards.
|
||||
type TrackCalendarLeaderboardResponse struct {
|
||||
Items []TrackCalendarLeaderboard `json:"items"`
|
||||
}
|
||||
|
||||
// Encode marshals an envelope to JSON bytes.
|
||||
func Encode(env *Envelope) ([]byte, error) {
|
||||
if env.TSMs == 0 {
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// Package main is the graphify utility. It walks the codebase, parses Go AST,
|
||||
// and outputs a highly compact symbol summary to server/CODEBASE.md.
|
||||
// This is used for token savings and helping developers/AI agents quickly map out imports and structures.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type PackageSummary struct {
|
||||
Name string
|
||||
Path string
|
||||
Doc string
|
||||
Structs []string
|
||||
Interfaces []string
|
||||
Functions []string
|
||||
Methods []string
|
||||
}
|
||||
|
||||
func main() {
|
||||
rootDir := "."
|
||||
outputFile := "CODEBASE.md"
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("# x0gp Go Codebase Map (Generated)\n\n")
|
||||
buf.WriteString("This file contains a compiled map of all packages, structures, interfaces, and public functions in the `server` directory. It is optimized for token-efficiency so AI agents and humans can understand the codebase layout without reading all files.\n\n")
|
||||
|
||||
pkgs := make(map[string]*PackageSummary)
|
||||
fset := token.NewFileSet()
|
||||
|
||||
err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
// Skip hidden and build dirs
|
||||
name := d.Name()
|
||||
if (name != "." && strings.HasPrefix(name, ".")) || name == "vendor" || name == "bin" || name == "docs" || name == "web" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(d.Name(), ".go") || strings.HasSuffix(d.Name(), "_test.go") {
|
||||
return nil
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
pkgSum, exists := pkgs[dir]
|
||||
if !exists {
|
||||
pkgSum = &PackageSummary{
|
||||
Path: dir,
|
||||
}
|
||||
pkgs[dir] = pkgSum
|
||||
}
|
||||
|
||||
node, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil // ignore unparseable files defensively
|
||||
}
|
||||
|
||||
pkgSum.Name = node.Name.Name
|
||||
if node.Doc != nil && pkgSum.Doc == "" {
|
||||
pkgSum.Doc = strings.TrimSpace(node.Doc.Text())
|
||||
}
|
||||
|
||||
// Inspect AST for declarations
|
||||
ast.Inspect(node, func(n ast.Node) bool {
|
||||
switch decl := n.(type) {
|
||||
case *ast.GenDecl:
|
||||
for _, spec := range decl.Specs {
|
||||
if typeSpec, ok := spec.(*ast.TypeSpec); ok {
|
||||
if !ast.IsExported(typeSpec.Name.Name) {
|
||||
continue
|
||||
}
|
||||
switch typeType := typeSpec.Type.(type) {
|
||||
case *ast.StructType:
|
||||
structDesc := fmt.Sprintf("- **struct %s**", typeSpec.Name.Name)
|
||||
if typeType.Fields != nil && len(typeType.Fields.List) > 0 {
|
||||
fields := []string{}
|
||||
for _, f := range typeType.Fields.List {
|
||||
for _, id := range f.Names {
|
||||
if ast.IsExported(id.Name) {
|
||||
fields = append(fields, id.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
structDesc += fmt.Sprintf(" { %s }", strings.Join(fields, ", "))
|
||||
}
|
||||
}
|
||||
pkgSum.Structs = append(pkgSum.Structs, structDesc)
|
||||
case *ast.InterfaceType:
|
||||
methods := []string{}
|
||||
if typeType.Methods != nil {
|
||||
for _, m := range typeType.Methods.List {
|
||||
for _, id := range m.Names {
|
||||
if ast.IsExported(id.Name) {
|
||||
methods = append(methods, id.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pkgSum.Interfaces = append(pkgSum.Interfaces, fmt.Sprintf("- **interface %s** { %s }", typeSpec.Name.Name, strings.Join(methods, ", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
case *ast.FuncDecl:
|
||||
if !ast.IsExported(decl.Name.Name) {
|
||||
return true
|
||||
}
|
||||
// Format function signature
|
||||
params := []string{}
|
||||
if decl.Type.Params != nil {
|
||||
for _, p := range decl.Type.Params.List {
|
||||
for range p.Names {
|
||||
params = append(params, formatType(p.Type))
|
||||
}
|
||||
if len(p.Names) == 0 {
|
||||
params = append(params, formatType(p.Type))
|
||||
}
|
||||
}
|
||||
}
|
||||
results := []string{}
|
||||
if decl.Type.Results != nil {
|
||||
for _, r := range decl.Type.Results.List {
|
||||
for range r.Names {
|
||||
results = append(results, formatType(r.Type))
|
||||
}
|
||||
if len(r.Names) == 0 {
|
||||
results = append(results, formatType(r.Type))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sig := fmt.Sprintf("%s(%s)", decl.Name.Name, strings.Join(params, ", "))
|
||||
if len(results) > 0 {
|
||||
if len(results) == 1 {
|
||||
sig += " " + results[0]
|
||||
} else {
|
||||
sig += " (" + strings.Join(results, ", ") + ")"
|
||||
}
|
||||
}
|
||||
|
||||
if decl.Recv != nil && len(decl.Recv.List) > 0 {
|
||||
recvType := formatType(decl.Recv.List[0].Type)
|
||||
pkgSum.Methods = append(pkgSum.Methods, fmt.Sprintf("- func (%s) %s", recvType, sig))
|
||||
} else {
|
||||
pkgSum.Functions = append(pkgSum.Functions, fmt.Sprintf("- func %s", sig))
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error gathering package summaries: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Write out gathered summaries sorted/grouped by path
|
||||
// (we can sort keys or print them grouped by internal vs cmd)
|
||||
paths := []string{}
|
||||
for p := range pkgs {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
// Simple sort: cmd/ first, then internal/
|
||||
// We want to present it nicely
|
||||
for _, path := range paths {
|
||||
sum := pkgs[path]
|
||||
cleanPath := filepath.ToSlash(path)
|
||||
buf.WriteString(fmt.Sprintf("## Package `%s` (`%s`)\n\n", sum.Name, cleanPath))
|
||||
if sum.Doc != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s\n\n", sum.Doc))
|
||||
}
|
||||
|
||||
if len(sum.Interfaces) > 0 {
|
||||
buf.WriteString("### Interfaces\n")
|
||||
for _, item := range sum.Interfaces {
|
||||
buf.WriteString(item + "\n")
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(sum.Structs) > 0 {
|
||||
buf.WriteString("### Structs\n")
|
||||
for _, item := range sum.Structs {
|
||||
buf.WriteString(item + "\n")
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(sum.Functions) > 0 {
|
||||
buf.WriteString("### Functions\n")
|
||||
for _, item := range sum.Functions {
|
||||
buf.WriteString(item + "\n")
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(sum.Methods) > 0 {
|
||||
buf.WriteString("### Methods\n")
|
||||
for _, item := range sum.Methods {
|
||||
buf.WriteString(item + "\n")
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
buf.WriteString("---\n\n")
|
||||
}
|
||||
|
||||
err = os.WriteFile(outputFile, buf.Bytes(), 0644)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing CODEBASE.md: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Generated %s successfully!\n", outputFile)
|
||||
}
|
||||
|
||||
func formatType(expr ast.Expr) string {
|
||||
switch t := expr.(type) {
|
||||
case *ast.Ident:
|
||||
return t.Name
|
||||
case *ast.StarExpr:
|
||||
return "*" + formatType(t.X)
|
||||
case *ast.SelectorExpr:
|
||||
return formatType(t.X) + "." + t.Sel.Name
|
||||
case *ast.ArrayType:
|
||||
return "[]" + formatType(t.Elt)
|
||||
case *ast.MapType:
|
||||
return "map[" + formatType(t.Key) + "]" + formatType(t.Value)
|
||||
case *ast.InterfaceType:
|
||||
return "interface{}"
|
||||
case *ast.FuncType:
|
||||
return "func"
|
||||
default:
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,35 @@
|
||||
<div class="wheel">
|
||||
<div class="wheel-disc" id="wheelDisc"></div>
|
||||
</div>
|
||||
|
||||
<div class="control-extra" style="display: flex; gap: 10px; margin-top: 15px; justify-content: space-around; border-top: 1px solid #333; padding-top: 12px;">
|
||||
<div class="gear-indicator" style="text-align: center;">
|
||||
<div style="font-size: 0.8rem; color: #aaa;">GEAR</div>
|
||||
<div style="display: flex; align-items: center; gap: 5px; margin-top: 4px;">
|
||||
<button id="btnGearDown" class="btn" style="padding: 2px 8px; font-size: 0.8rem;">-</button>
|
||||
<b id="lblGear" style="font-size: 1.3rem; min-width: 15px; display: inline-block;">N</b>
|
||||
<button id="btnGearUp" class="btn" style="padding: 2px 8px; font-size: 0.8rem;">+</button>
|
||||
</div>
|
||||
<div style="font-size: 0.7rem; color: #888; margin-top: 2px;">(Z / X)</div>
|
||||
</div>
|
||||
<div class="sys-toggle" style="text-align: center;">
|
||||
<div style="font-size: 0.8rem; color: #aaa;">DRS</div>
|
||||
<button id="btnDrs" class="btn" style="padding: 2px 6px; font-size: 0.8rem; margin-top: 4px;">Toggle</button>
|
||||
<div id="lblDrs" class="val" style="font-weight: bold; font-size: 0.95rem; margin-top: 4px; color: #ff3b30;">OFF</div>
|
||||
<div style="font-size: 0.7rem; color: #888; margin-top: 2px;">(Q)</div>
|
||||
</div>
|
||||
<div class="sys-toggle" style="text-align: center;">
|
||||
<div style="font-size: 0.8rem; color: #aaa;">PIT LIMIT</div>
|
||||
<button id="btnPit" class="btn" style="padding: 2px 6px; font-size: 0.8rem; margin-top: 4px;">Toggle</button>
|
||||
<div id="lblPit" class="val" style="font-weight: bold; font-size: 0.95rem; margin-top: 4px; color: #ff3b30;">OFF</div>
|
||||
<div style="font-size: 0.7rem; color: #888; margin-top: 2px;">(E)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px; border-top: 1px solid #333; padding-top: 12px;">
|
||||
<span style="font-size: 0.8rem; color: #aaa;">Пакет управления (JSON):</span>
|
||||
<pre id="ctrlJson" style="font-family: monospace; font-size: 0.75rem; background: #111; border: 1px solid #222; padding: 8px; border-radius: 4px; color: #00ff66; margin: 6px 0 0 0; overflow-x: auto; max-height: 250px; text-align: left; line-height: 1.2;">{}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
+100
-1
@@ -35,6 +35,14 @@
|
||||
tPos: $('tPos'),
|
||||
tTsm: $('tTsm'),
|
||||
tCars: $('tCars'),
|
||||
lblGear: $('lblGear'),
|
||||
lblDrs: $('lblDrs'),
|
||||
lblPit: $('lblPit'),
|
||||
btnDrs: $('btnDrs'),
|
||||
btnPit: $('btnPit'),
|
||||
btnGearUp: $('btnGearUp'),
|
||||
btnGearDown: $('btnGearDown'),
|
||||
ctrlJson: $('ctrlJson'),
|
||||
};
|
||||
|
||||
// -------- State --------
|
||||
@@ -53,7 +61,7 @@
|
||||
snapshotCount: 0,
|
||||
snapshotWindow: [],
|
||||
keys: new Set(),
|
||||
input: { throttle: 0, brake: 0, steer: 0, handbrake: false, buttons: 0 },
|
||||
input: { throttle: 0, brake: 0, steer: 0, handbrake: false, buttons: 0, gear: 0, drs: false, pitLimiter: false },
|
||||
pingTimer: null,
|
||||
inputTimer: null,
|
||||
slotHint: 0, // сервер выдаёт слот; 0 по умолчанию
|
||||
@@ -113,6 +121,12 @@
|
||||
state.input.steer = steer;
|
||||
state.input.handbrake = handbrake;
|
||||
|
||||
// Calculate buttons bitmask: bit 0 (DRS), bit 1 (Pit Limiter)
|
||||
let buttons = 0;
|
||||
if (state.input.drs) buttons |= 1;
|
||||
if (state.input.pitLimiter) buttons |= 2;
|
||||
state.input.buttons = buttons;
|
||||
|
||||
// Update UI bars
|
||||
ui.lblThrottle.textContent = throttle.toFixed(2);
|
||||
ui.lblBrake.textContent = brake.toFixed(2);
|
||||
@@ -129,6 +143,48 @@
|
||||
ui.barSteer.style.width = '2%';
|
||||
|
||||
ui.wheelDisc.style.transform = `rotate(${steer * 90}deg)`;
|
||||
|
||||
// Update Gear, DRS, Pit Limiter UI text & class if elements exist
|
||||
if (ui.lblGear) {
|
||||
let gName = 'N';
|
||||
if (state.input.gear === -1) gName = 'R';
|
||||
if (state.input.gear === 1) gName = 'D';
|
||||
ui.lblGear.textContent = gName;
|
||||
}
|
||||
if (ui.lblDrs) {
|
||||
ui.lblDrs.textContent = state.input.drs ? 'ON' : 'OFF';
|
||||
ui.lblDrs.className = state.input.drs ? 'val active' : 'val';
|
||||
}
|
||||
if (ui.lblPit) {
|
||||
ui.lblPit.textContent = state.input.pitLimiter ? 'ON' : 'OFF';
|
||||
ui.lblPit.className = state.input.pitLimiter ? 'val active' : 'val';
|
||||
}
|
||||
|
||||
// Format control packet as JSON and show in UI
|
||||
if (ui.ctrlJson) {
|
||||
const steerByte = Math.round((steer + 1.0) * 100);
|
||||
const throttleByte = Math.round(throttle * 100);
|
||||
const brakeByte = Math.round(brake * 100);
|
||||
const gearByte = state.input.gear === -1 ? 0 : (state.input.gear === 1 ? 2 : 1);
|
||||
|
||||
const jsonStr = JSON.stringify({
|
||||
sent_json: {
|
||||
steering: steer,
|
||||
throttle: throttle,
|
||||
brake: brake,
|
||||
gear: state.input.gear,
|
||||
buttons: buttons
|
||||
},
|
||||
binary_packet_bytes: {
|
||||
byte0_steer: `0x${steerByte.toString(16).toUpperCase().padStart(2, '0')} (${steerByte})`,
|
||||
byte1_throttle: `0x${throttleByte.toString(16).toUpperCase().padStart(2, '0')} (${throttleByte}%)`,
|
||||
byte2_brake: `0x${brakeByte.toString(16).toUpperCase().padStart(2, '0')} (${brakeByte}%)`,
|
||||
byte3_gear: `0x${gearByte.toString(16).toUpperCase().padStart(2, '0')} (${gearByte === 0 ? 'R' : (gearByte === 2 ? 'D' : 'N')})`,
|
||||
byte4_flags: `0x${buttons.toString(16).toUpperCase().padStart(2, '0')} (DRS=${state.input.drs ? 1 : 0}, Pit=${state.input.pitLimiter ? 1 : 0})`
|
||||
}
|
||||
}, null, 2);
|
||||
ui.ctrlJson.textContent = jsonStr;
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Send helpers --------
|
||||
@@ -450,6 +506,24 @@
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||
if (['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k)) e.preventDefault();
|
||||
state.keys.add(k);
|
||||
|
||||
// Toggle DRS, Pit Limiter, and shift gears
|
||||
if (k === 'q') {
|
||||
state.input.drs = !state.input.drs;
|
||||
pollInput();
|
||||
}
|
||||
if (k === 'e') {
|
||||
state.input.pitLimiter = !state.input.pitLimiter;
|
||||
pollInput();
|
||||
}
|
||||
if (k === 'z') {
|
||||
state.input.gear = Math.max(-1, state.input.gear - 1);
|
||||
pollInput();
|
||||
}
|
||||
if (k === 'x') {
|
||||
state.input.gear = Math.min(1, state.input.gear + 1);
|
||||
pollInput();
|
||||
}
|
||||
});
|
||||
window.addEventListener('keyup', (e) => {
|
||||
state.keys.delete(keyName(e));
|
||||
@@ -462,6 +536,31 @@
|
||||
ui.btnJoin.addEventListener('click', joinRace);
|
||||
ui.btnLeave.addEventListener('click', leaveRace);
|
||||
|
||||
if (ui.btnDrs) {
|
||||
ui.btnDrs.addEventListener('click', () => {
|
||||
state.input.drs = !state.input.drs;
|
||||
pollInput();
|
||||
});
|
||||
}
|
||||
if (ui.btnPit) {
|
||||
ui.btnPit.addEventListener('click', () => {
|
||||
state.input.pitLimiter = !state.input.pitLimiter;
|
||||
pollInput();
|
||||
});
|
||||
}
|
||||
if (ui.btnGearUp) {
|
||||
ui.btnGearUp.addEventListener('click', () => {
|
||||
state.input.gear = Math.min(1, state.input.gear + 1);
|
||||
pollInput();
|
||||
});
|
||||
}
|
||||
if (ui.btnGearDown) {
|
||||
ui.btnGearDown.addEventListener('click', () => {
|
||||
state.input.gear = Math.max(-1, state.input.gear - 1);
|
||||
pollInput();
|
||||
});
|
||||
}
|
||||
|
||||
// -------- Boot --------
|
||||
setConn('disconnected', '');
|
||||
log('x0gp PoC client ready. Press Connect.', 'meta');
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>x0gp — Race Track Viewer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&family=Plus+Jakarta+Sans:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #0b0f19;
|
||||
--card-bg: rgba(17, 24, 39, 0.7);
|
||||
--card-border: rgba(255, 255, 255, 0.08);
|
||||
--text-primary: #f3f4f6;
|
||||
--text-secondary: #9ca3af;
|
||||
--accent: #3b82f6;
|
||||
--accent-glow: rgba(59, 130, 246, 0.15);
|
||||
--gradient-accent: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
padding: 2.5rem;
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 20%, rgba(59, 130, 246, 0.05) 0%, transparent 40%),
|
||||
radial-gradient(circle at 90% 80%, rgba(37, 99, 235, 0.05) 0%, transparent 40%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
header {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 3rem auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #a5b4fc 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
backdrop-filter: blur(12px);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(800px circle at var(--x, 0px) var(--y, 0px), rgba(255, 255, 255, 0.06), transparent 40%);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
|
||||
.card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.track-name {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.track-id {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
background-color: #0b0f19;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 20px;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.tag-active {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #34d399;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.loader {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 300px;
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(59, 130, 246, 0.1);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div>
|
||||
<h1>x0gp — Race Track Catalog</h1>
|
||||
<div class="subtitle">Visualizing physical tracks and their generated centerline geometry</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="grid" id="tracks-grid">
|
||||
<div class="loader">
|
||||
<div class="spinner"></div>
|
||||
<div>Loading tracks from server API...</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Track SVG centerline rendering algorithm
|
||||
function generateSvgPath(centerline, bounds, padding = 30, size = 260) {
|
||||
if (!centerline || centerline.length < 2) return null;
|
||||
|
||||
const minX = bounds.min_x;
|
||||
const maxX = bounds.max_x;
|
||||
const minY = bounds.min_y;
|
||||
const maxY = bounds.max_y;
|
||||
const rangeX = maxX - minX || 1;
|
||||
const rangeY = maxY - minY || 1;
|
||||
|
||||
const aspectRatio = rangeX / rangeY;
|
||||
let width, height;
|
||||
if (aspectRatio > 1) {
|
||||
width = size;
|
||||
height = size / aspectRatio;
|
||||
} else {
|
||||
width = size * aspectRatio;
|
||||
height = size;
|
||||
}
|
||||
|
||||
const canvasWidth = width + padding * 2;
|
||||
const canvasHeight = height + padding * 2;
|
||||
|
||||
const pixelPoints = centerline.map((point) => ({
|
||||
x: ((point.x - minX) / rangeX) * width + padding,
|
||||
y: ((point.y - minY) / rangeY) * height + padding,
|
||||
heading: point.heading_rad,
|
||||
curvature: point.curvature
|
||||
}));
|
||||
|
||||
let dAttribute = '';
|
||||
|
||||
for (let i = 0; i < pixelPoints.length; i++) {
|
||||
const current = pixelPoints[i];
|
||||
if (i === 0) {
|
||||
dAttribute += `M ${current.x.toFixed(2)} ${current.y.toFixed(2)}`;
|
||||
} else {
|
||||
const prev = pixelPoints[i - 1];
|
||||
const dx = current.x - prev.x;
|
||||
const dy = current.y - prev.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const tension = 0.30 + (prev.curvature * 0.05);
|
||||
const cpLength = distance * tension;
|
||||
const cp1x = prev.x + Math.cos(prev.heading) * cpLength;
|
||||
const cp1y = prev.y + Math.sin(prev.heading) * cpLength;
|
||||
const cp2x = current.x - Math.cos(current.heading) * cpLength;
|
||||
const cp2y = current.y - Math.sin(current.heading) * cpLength;
|
||||
dAttribute += ` C ${cp1x.toFixed(2)} ${cp1y.toFixed(2)}, ${cp2x.toFixed(2)} ${cp2y.toFixed(2)}, ${current.x.toFixed(2)} ${current.y.toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (pixelPoints.length > 2) {
|
||||
const first = pixelPoints[0];
|
||||
const last = pixelPoints[pixelPoints.length - 1];
|
||||
const dx = first.x - last.x;
|
||||
const dy = first.y - last.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const tension = 0.3 + last.curvature * 0.05;
|
||||
const cpLength = distance * tension;
|
||||
const cp1x = last.x + Math.cos(last.heading) * cpLength;
|
||||
const cp1y = last.y + Math.sin(last.heading) * cpLength;
|
||||
const cp2x = first.x - Math.cos(first.heading) * cpLength;
|
||||
const cp2y = first.y - Math.sin(first.heading) * cpLength;
|
||||
dAttribute += ` C ${cp1x.toFixed(2)} ${cp1y.toFixed(2)}, ${cp2x.toFixed(2)} ${cp2y.toFixed(2)}, ${first.x.toFixed(2)} ${first.y.toFixed(2)} Z`;
|
||||
}
|
||||
|
||||
return {
|
||||
d: dAttribute,
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
};
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const grid = document.getElementById('tracks-grid');
|
||||
try {
|
||||
const res = await fetch('/api/tracks');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
grid.innerHTML = '';
|
||||
if (!data.tracks || data.tracks.length === 0) {
|
||||
grid.innerHTML = '<div class="loader">No tracks found on the server.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
data.tracks.forEach(track => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
|
||||
// Track hover mouse light effect
|
||||
card.addEventListener('mousemove', e => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
card.style.setProperty('--x', `${x}px`);
|
||||
card.style.setProperty('--y', `${y}px`);
|
||||
});
|
||||
|
||||
// Generate SVG
|
||||
const svgData = generateSvgPath(track.centerline, track.bounds);
|
||||
let svgHtml = '<div style="color: var(--text-secondary); font-size: 0.85rem;">No centerline coordinates</div>';
|
||||
if (svgData) {
|
||||
svgHtml = `
|
||||
<svg width="100%" height="100%" viewBox="0 0 ${svgData.canvasWidth} ${svgData.canvasHeight}" preserveAspectRatio="xMidYMid meet" style="display: block; max-width: 100%; max-height: 100%; height: auto; transform-origin: center;">
|
||||
<path d="${svgData.d}" fill="none" stroke="#ffffff" stroke-width="20" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="${svgData.d}" fill="none" stroke="#111827" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
const isActiveTag = track.is_active ? '<span class="tag tag-active">Active Track</span>' : '';
|
||||
const bestLapStr = track.best_lap_ms > 0 ? `${(track.best_lap_ms / 1000).toFixed(3)}s` : 'None';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="track-name">${track.name}</div>
|
||||
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-top: 0.2rem;">${track.description || 'No description'}</div>
|
||||
</div>
|
||||
<div class="track-id">${track.id}</div>
|
||||
</div>
|
||||
|
||||
<div class="canvas-container">
|
||||
${svgHtml}
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Length</span>
|
||||
<span class="stat-value">${track.length_m.toFixed(1)} m</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Width</span>
|
||||
<span class="stat-value">${track.width_m.toFixed(1)} m</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Lane Width</span>
|
||||
<span class="stat-value">${track.lane_width_m.toFixed(1)} m</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Best Lap</span>
|
||||
<span class="stat-value">${bestLapStr}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tags-container">
|
||||
<span class="tag" style="background: rgba(255,255,255,0.05); color: #fff; border-color: rgba(255,255,255,0.1); text-transform: capitalize;">${track.surface}</span>
|
||||
${isActiveTag}
|
||||
</div>
|
||||
`;
|
||||
|
||||
grid.appendChild(card);
|
||||
});
|
||||
} catch (err) {
|
||||
grid.innerHTML = `<div class="loader" style="color: #ef4444;">Failed to load tracks: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,876 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>x0gp · WebRTC Cockpit</title>
|
||||
<!-- Modern typography -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #0b0f19;
|
||||
--panel-bg: rgba(17, 24, 39, 0.7);
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--primary: #10b981; /* Emerald */
|
||||
--primary-hover: #059669;
|
||||
--accent: #3b82f6; /* Blue */
|
||||
--text-main: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
--danger: #ef4444;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 20%, rgba(16, 185, 129, 0.05) 0%, transparent 40%),
|
||||
radial-gradient(circle at 90% 80%, rgba(59, 130, 246, 0.05) 0%, transparent 40%);
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 1.5rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 800;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: -0.05em;
|
||||
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--danger);
|
||||
box-shadow: 0 0 8px var(--danger);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background-color: var(--primary);
|
||||
box-shadow: 0 0 8px var(--primary);
|
||||
}
|
||||
|
||||
.status-dot.connecting {
|
||||
background-color: #f59e0b;
|
||||
box-shadow: 0 0 8px #f59e0b;
|
||||
animation: pulse 1s infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
from { opacity: 0.5; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
main {
|
||||
grid-template-columns: 1.6fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-panel:hover {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.auth-bar {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--text-main);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
color: white;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
button.disconnect {
|
||||
background: linear-gradient(135deg, var(--danger), #dc2626);
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
background: #020617;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
box-shadow: inset 0 0 40px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.video-feed {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.video-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.video-placeholder svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
stroke: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
.telemetry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.telemetry-card {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.telemetry-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.telemetry-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.controller-viz {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.steering-wheel-container {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.steering-wheel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.1s ease-out;
|
||||
transform-origin: center center;
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.keys-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.key-btn {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
height: 45px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.1s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.key-btn.active {
|
||||
background: var(--primary);
|
||||
color: #0b0f19;
|
||||
box-shadow: 0 0 12px var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.key-btn.empty {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.console {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
height: 180px;
|
||||
overflow-y: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #10b981;
|
||||
box-shadow: inset 0 0 10px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.console-entry {
|
||||
margin-bottom: 0.35rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.console-time {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.console-in {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.console-out {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 11 22 2 13 21 11 13 3 11"/></svg>
|
||||
x0gp <span style="font-weight: 300;">Cockpit</span>
|
||||
</div>
|
||||
<div class="status-badge">
|
||||
<div id="statusDot" class="status-dot"></div>
|
||||
<span id="statusText">Disconnected</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Left: Video Feed Panel -->
|
||||
<div class="glass-panel" style="display: flex; flex-direction: column;">
|
||||
<h2>
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||
Real-Time Video Feed
|
||||
</h2>
|
||||
<div class="video-container">
|
||||
<img id="videoFeed" class="video-feed" alt="WebRTC Stream">
|
||||
<div id="videoPlaceholder" class="video-placeholder">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.182 16.318A4.486 4.486 0 0012.016 15a4.486 4.486 0 00-3.198 1.318M21 12a9 9 0 11-18 0 9 9 0 0118 0zM9.75 9.75c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75zm-.375 0h.008v.015h-.008V9.75zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75zm-.375 0h.008v.015h-.008V9.75z"/></svg>
|
||||
<p>Connect the cockpit to receive real-time video</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controller-viz">
|
||||
<div class="steering-wheel-container">
|
||||
<!-- Steering wheel SVG -->
|
||||
<svg id="steeringWheel" class="steering-wheel" viewBox="0 0 100 100" fill="none">
|
||||
<circle cx="50" cy="50" r="45" stroke="#f3f4f6" stroke-width="6"/>
|
||||
<circle cx="50" cy="50" r="38" stroke="var(--primary)" stroke-width="2" stroke-dasharray="5 5"/>
|
||||
<rect x="15" y="47" width="70" height="6" rx="3" fill="#f3f4f6"/>
|
||||
<rect x="47" y="50" width="6" height="35" rx="3" fill="#f3f4f6"/>
|
||||
<circle cx="50" cy="50" r="10" fill="#0b0f19" stroke="#f3f4f6" stroke-width="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="keys-grid">
|
||||
<div class="key-btn empty"></div>
|
||||
<div id="keyW" class="key-btn">W</div>
|
||||
<div class="key-btn empty"></div>
|
||||
<div id="keyA" class="key-btn">A</div>
|
||||
<div id="keyS" class="key-btn">S</div>
|
||||
<div id="keyD" class="key-btn">D</div>
|
||||
</div>
|
||||
|
||||
<div class="control-extra" style="display: flex; gap: 10px; margin-top: 15px; justify-content: space-around; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 12px; width: 100%;">
|
||||
<div class="gear-indicator" style="text-align: center;">
|
||||
<div style="font-size: 0.8rem; color: #8a8d9a;">GEAR</div>
|
||||
<div style="display: flex; align-items: center; gap: 5px; margin-top: 4px;">
|
||||
<button id="btnGearDown" class="btn" style="padding: 2px 8px; font-size: 0.8rem; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; border-radius: 4px; cursor: pointer;">-</button>
|
||||
<b id="lblGear" style="font-size: 1.3rem; min-width: 15px; display: inline-block;">N</b>
|
||||
<button id="btnGearUp" class="btn" style="padding: 2px 8px; font-size: 0.8rem; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; border-radius: 4px; cursor: pointer;">+</button>
|
||||
</div>
|
||||
<div style="font-size: 0.7rem; color: #6b7280; margin-top: 2px;">(Z / X)</div>
|
||||
</div>
|
||||
<div class="sys-toggle" style="text-align: center;">
|
||||
<div style="font-size: 0.8rem; color: #8a8d9a;">DRS</div>
|
||||
<button id="btnDrs" class="btn" style="padding: 2px 6px; font-size: 0.8rem; margin-top: 4px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; border-radius: 4px; cursor: pointer;">Toggle</button>
|
||||
<div id="lblDrs" class="val" style="font-weight: bold; font-size: 0.95rem; margin-top: 4px; color: #ef4444;">OFF</div>
|
||||
<div style="font-size: 0.7rem; color: #6b7280; margin-top: 2px;">(Q)</div>
|
||||
</div>
|
||||
<div class="sys-toggle" style="text-align: center;">
|
||||
<div style="font-size: 0.8rem; color: #8a8d9a;">PIT LIMIT</div>
|
||||
<button id="btnPit" class="btn" style="padding: 2px 6px; font-size: 0.8rem; margin-top: 4px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; border-radius: 4px; cursor: pointer;">Toggle</button>
|
||||
<div id="lblPit" class="val" style="font-weight: bold; font-size: 0.95rem; margin-top: 4px; color: #ef4444;">OFF</div>
|
||||
<div style="font-size: 0.7rem; color: #6b7280; margin-top: 2px;">(E)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 12px; width: 100%;">
|
||||
<span style="font-size: 0.8rem; color: #8a8d9a;">Пакет управления (JSON):</span>
|
||||
<pre id="ctrlJson" style="font-family: monospace; font-size: 0.75rem; background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.05); padding: 8px; border-radius: 4px; color: #00ff66; margin: 6px 0 0 0; overflow-x: auto; max-height: 250px; text-align: left; line-height: 1.2;">{}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Telemetry & Controls Panel -->
|
||||
<div class="glass-panel" style="display: flex; flex-direction: column; gap: 1.5rem;">
|
||||
<div>
|
||||
<h2>
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Session Control
|
||||
</h2>
|
||||
<div class="auth-bar">
|
||||
<input type="text" id="driverIdInput" placeholder="Enter Driver ID (e.g. ACE)" value="ACE">
|
||||
<button id="connectBtn">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 002 2h2a2 2 0 002-2z"/></svg>
|
||||
Real-time Telemetry
|
||||
</h2>
|
||||
<div class="telemetry-grid">
|
||||
<div class="telemetry-card">
|
||||
<div class="telemetry-label">Speed</div>
|
||||
<div id="telSpeed" class="telemetry-value">0.00 <span style="font-size: 0.8rem; font-weight: normal;">m/s</span></div>
|
||||
</div>
|
||||
<div class="telemetry-card">
|
||||
<div class="telemetry-label">Ping Latency</div>
|
||||
<div id="telPing" class="telemetry-value">0 <span style="font-size: 0.8rem; font-weight: normal;">ms</span></div>
|
||||
</div>
|
||||
<div class="telemetry-card">
|
||||
<div class="telemetry-label">Car ID</div>
|
||||
<div id="telDevice" class="telemetry-value">-</div>
|
||||
</div>
|
||||
<div class="telemetry-card">
|
||||
<div class="telemetry-label">Position (X, Y)</div>
|
||||
<div id="telPos" class="telemetry-value">0.0, 0.0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 1; display: flex; flex-direction: column;">
|
||||
<h2>
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
Console Log
|
||||
</h2>
|
||||
<div id="consoleLog" class="console">
|
||||
<div class="console-entry"><span class="console-time">[18:58:00]</span> Welcome to x0gp Cockpit. Enter Driver ID and click Connect to initialize WebRTC.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
x0gp · Pure Go WebRTC 1-to-1 Proxy System
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
const statusText = document.getElementById('statusText');
|
||||
const driverIdInput = document.getElementById('driverIdInput');
|
||||
const connectBtn = document.getElementById('connectBtn');
|
||||
const videoFeed = document.getElementById('videoFeed');
|
||||
const videoPlaceholder = document.getElementById('videoPlaceholder');
|
||||
const consoleLog = document.getElementById('consoleLog');
|
||||
const steeringWheel = document.getElementById('steeringWheel');
|
||||
|
||||
// Telemetry DOM
|
||||
const telSpeed = document.getElementById('telSpeed');
|
||||
const telPing = document.getElementById('telPing');
|
||||
const telDevice = document.getElementById('telDevice');
|
||||
const telPos = document.getElementById('telPos');
|
||||
|
||||
let pc = null;
|
||||
let dataChannel = null;
|
||||
let videoChannel = null;
|
||||
let sendControlsInterval = null;
|
||||
let activeKeys = { w: false, a: false, s: false, d: false };
|
||||
let currentSteer = 0; // -1 to 1
|
||||
let currentThrottle = 0; // 0 to 1
|
||||
let currentBrake = 0; // 0 to 1
|
||||
let currentGear = 0; // 0: N, -1: R, 1: D
|
||||
let drs = false;
|
||||
let pitLimiter = false;
|
||||
let buttons = 0;
|
||||
let pingsReceived = 0;
|
||||
let latencyStart = 0;
|
||||
|
||||
function log(message, type = 'system') {
|
||||
const time = new Date().toTimeString().split(' ')[0];
|
||||
const div = document.createElement('div');
|
||||
div.className = 'console-entry';
|
||||
|
||||
let prefix = '';
|
||||
if (type === 'in') prefix = ' <span class="console-in"><-</span> ';
|
||||
if (type === 'out') prefix = ' <span class="console-out">-></span> ';
|
||||
|
||||
div.innerHTML = `<span class="console-time">[${time}]</span>${prefix}${message}`;
|
||||
consoleLog.appendChild(div);
|
||||
consoleLog.scrollTop = consoleLog.scrollHeight;
|
||||
}
|
||||
|
||||
// Keyboard event listeners
|
||||
window.addEventListener('keydown', (e) => {
|
||||
const key = e.key.toLowerCase();
|
||||
if (activeKeys.hasOwnProperty(key)) {
|
||||
activeKeys[key] = true;
|
||||
updateKeyVisuals();
|
||||
}
|
||||
if (key === 'q') {
|
||||
drs = !drs;
|
||||
updateKeyVisuals();
|
||||
}
|
||||
if (key === 'e') {
|
||||
pitLimiter = !pitLimiter;
|
||||
updateKeyVisuals();
|
||||
}
|
||||
if (key === 'z') {
|
||||
currentGear = Math.max(-1, currentGear - 1);
|
||||
updateKeyVisuals();
|
||||
}
|
||||
if (key === 'x') {
|
||||
currentGear = Math.min(1, currentGear + 1);
|
||||
updateKeyVisuals();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('keyup', (e) => {
|
||||
const key = e.key.toLowerCase();
|
||||
if (activeKeys.hasOwnProperty(key)) {
|
||||
activeKeys[key] = false;
|
||||
updateKeyVisuals();
|
||||
}
|
||||
});
|
||||
|
||||
function updateKeyVisuals() {
|
||||
document.getElementById('keyW').classList.toggle('active', activeKeys.w);
|
||||
document.getElementById('keyA').classList.toggle('active', activeKeys.a);
|
||||
document.getElementById('keyS').classList.toggle('active', activeKeys.s);
|
||||
document.getElementById('keyD').classList.toggle('active', activeKeys.d);
|
||||
|
||||
// Calculate wheel rotation based on keys
|
||||
let targetRotation = 0;
|
||||
if (activeKeys.a) targetRotation = -45;
|
||||
if (activeKeys.d) targetRotation = 45;
|
||||
steeringWheel.style.transform = `rotate(${targetRotation}deg)`;
|
||||
|
||||
// Compute values
|
||||
currentThrottle = activeKeys.w ? 1.0 : 0.0;
|
||||
currentBrake = activeKeys.s ? 1.0 : 0.0;
|
||||
currentSteer = 0.0;
|
||||
if (activeKeys.a) currentSteer = -1.0;
|
||||
if (activeKeys.d) currentSteer = 1.0;
|
||||
|
||||
buttons = 0;
|
||||
if (drs) buttons |= 1;
|
||||
if (pitLimiter) buttons |= 2;
|
||||
|
||||
// Update UI Gear/DRS/Pit if elements exist
|
||||
const lblGear = document.getElementById('lblGear');
|
||||
if (lblGear) {
|
||||
let gName = 'N';
|
||||
if (currentGear === -1) gName = 'R';
|
||||
if (currentGear === 1) gName = 'D';
|
||||
lblGear.innerText = gName;
|
||||
}
|
||||
const lblDrs = document.getElementById('lblDrs');
|
||||
if (lblDrs) {
|
||||
lblDrs.innerText = drs ? 'ON' : 'OFF';
|
||||
lblDrs.style.color = drs ? '#00ff66' : '#ef4444';
|
||||
}
|
||||
const lblPit = document.getElementById('lblPit');
|
||||
if (lblPit) {
|
||||
lblPit.innerText = pitLimiter ? 'ON' : 'OFF';
|
||||
lblPit.style.color = pitLimiter ? '#00ff66' : '#ef4444';
|
||||
}
|
||||
|
||||
const ctrlJson = document.getElementById('ctrlJson');
|
||||
if (ctrlJson) {
|
||||
const steerByte = Math.round((currentSteer + 1.0) * 100);
|
||||
const throttleByte = Math.round(currentThrottle * 100);
|
||||
const brakeByte = Math.round(currentBrake * 100);
|
||||
const gearByte = currentGear === -1 ? 0 : (currentGear === 1 ? 2 : 1);
|
||||
|
||||
ctrlJson.innerText = JSON.stringify({
|
||||
sent_json: {
|
||||
steering: currentSteer,
|
||||
throttle: currentThrottle,
|
||||
brake: currentBrake,
|
||||
gear: currentGear,
|
||||
buttons: buttons
|
||||
},
|
||||
binary_packet_bytes: {
|
||||
byte0_steer: `0x${steerByte.toString(16).toUpperCase().padStart(2, '0')} (${steerByte})`,
|
||||
byte1_throttle: `0x${throttleByte.toString(16).toUpperCase().padStart(2, '0')} (${throttleByte}%)`,
|
||||
byte2_brake: `0x${brakeByte.toString(16).toUpperCase().padStart(2, '0')} (${brakeByte}%)`,
|
||||
byte3_gear: `0x${gearByte.toString(16).toUpperCase().padStart(2, '0')} (${gearByte === 0 ? 'R' : (gearByte === 2 ? 'D' : 'N')})`,
|
||||
byte4_flags: `0x${buttons.toString(16).toUpperCase().padStart(2, '0')} (DRS=${drs ? 1 : 0}, Pit=${pitLimiter ? 1 : 0})`
|
||||
}
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
// Log control changes to UI console
|
||||
log(`Controls updated: throttle=${currentThrottle}, steering=${currentSteer}, brake=${currentBrake}, gear=${currentGear}, buttons=${buttons}`, 'out');
|
||||
}
|
||||
|
||||
// Click listeners for the custom controls
|
||||
document.getElementById('btnGearDown').addEventListener('click', () => {
|
||||
currentGear = Math.max(-1, currentGear - 1);
|
||||
updateKeyVisuals();
|
||||
});
|
||||
document.getElementById('btnGearUp').addEventListener('click', () => {
|
||||
currentGear = Math.min(1, currentGear + 1);
|
||||
updateKeyVisuals();
|
||||
});
|
||||
document.getElementById('btnDrs').addEventListener('click', () => {
|
||||
drs = !drs;
|
||||
updateKeyVisuals();
|
||||
});
|
||||
document.getElementById('btnPit').addEventListener('click', () => {
|
||||
pitLimiter = !pitLimiter;
|
||||
updateKeyVisuals();
|
||||
});
|
||||
|
||||
connectBtn.addEventListener('click', () => {
|
||||
if (pc) {
|
||||
disconnectCockpit();
|
||||
} else {
|
||||
connectCockpit();
|
||||
}
|
||||
});
|
||||
|
||||
async function connectCockpit() {
|
||||
const driverId = driverIdInput.value.trim().toUpperCase();
|
||||
if (!driverId) {
|
||||
alert('Please enter a valid Driver ID');
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Initializing WebRTC Connection for Driver: ${driverId}...`);
|
||||
updateStatus('connecting', 'Connecting...');
|
||||
|
||||
connectBtn.disabled = true;
|
||||
|
||||
try {
|
||||
// 1. Create PeerConnection
|
||||
pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||
});
|
||||
|
||||
// 2. Create Data Channels (Client Initiated)
|
||||
dataChannel = pc.createDataChannel('data', { ordered: true });
|
||||
videoChannel = pc.createDataChannel('video', { ordered: false });
|
||||
|
||||
setupDataChannel(dataChannel);
|
||||
setupVideoChannel(videoChannel);
|
||||
|
||||
// 3. Create SDP Offer
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
// 4. Wait for ICE gathering to complete (Vanilla ICE)
|
||||
log('Gathering ICE Candidates...');
|
||||
await new Promise((resolve) => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
resolve();
|
||||
} else {
|
||||
function checkState() {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
pc.removeEventListener('icegatheringstatechange', checkState);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
pc.addEventListener('icegatheringstatechange', checkState);
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Post SDP Offer to Server
|
||||
const signalingUrl = `${window.location.protocol}//localhost:8080/api/webrtc/connect`;
|
||||
log(`Sending SDP Offer to ${signalingUrl}...`);
|
||||
|
||||
const response = await fetch(signalingUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sdp: pc.localDescription.sdp,
|
||||
type: 'offer',
|
||||
driver_id: driverId
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const answer = await response.json();
|
||||
log('SDP Answer received from server. Establishing WebRTC channel...');
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
||||
|
||||
} catch (err) {
|
||||
log(`WebRTC connection failed: ${err.message}`, 'system');
|
||||
updateStatus('disconnected', 'Failed');
|
||||
disconnectCockpit();
|
||||
} finally {
|
||||
connectBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectCockpit() {
|
||||
log('Disconnecting WebRTC Cockpit...');
|
||||
|
||||
if (sendControlsInterval) {
|
||||
clearInterval(sendControlsInterval);
|
||||
sendControlsInterval = null;
|
||||
}
|
||||
|
||||
if (dataChannel) {
|
||||
dataChannel.close();
|
||||
dataChannel = null;
|
||||
}
|
||||
|
||||
if (videoChannel) {
|
||||
videoChannel.close();
|
||||
videoChannel = null;
|
||||
}
|
||||
|
||||
if (pc) {
|
||||
pc.close();
|
||||
pc = null;
|
||||
}
|
||||
|
||||
videoFeed.style.display = 'none';
|
||||
videoPlaceholder.style.display = 'flex';
|
||||
updateStatus('disconnected', 'Disconnected');
|
||||
connectBtn.innerText = 'Connect';
|
||||
connectBtn.classList.remove('disconnect');
|
||||
|
||||
// Reset Telemetry
|
||||
telSpeed.innerHTML = '0.00 <span style="font-size: 0.8rem; font-weight: normal;">m/s</span>';
|
||||
telPing.innerHTML = '0 <span style="font-size: 0.8rem; font-weight: normal;">ms</span>';
|
||||
telDevice.innerHTML = '-';
|
||||
telPos.innerHTML = '0.0, 0.0';
|
||||
}
|
||||
|
||||
function setupDataChannel(dc) {
|
||||
dc.onopen = () => {
|
||||
log(`Bidirectional DataChannel ('${dc.label}') is OPEN`, 'system');
|
||||
updateStatus('connected', 'Connected');
|
||||
connectBtn.innerText = 'Disconnect';
|
||||
connectBtn.classList.add('disconnect');
|
||||
|
||||
// Start sending steering controls at 30Hz
|
||||
sendControlsInterval = setInterval(sendControls, 33);
|
||||
};
|
||||
|
||||
dc.onclose = () => {
|
||||
log(`DataChannel ('${dc.label}') is CLOSED`, 'system');
|
||||
disconnectCockpit();
|
||||
};
|
||||
|
||||
dc.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'telemetry') {
|
||||
// Update Telemetry Panel
|
||||
telSpeed.innerHTML = `${msg.speed.toFixed(2)} <span style="font-size: 0.8rem; font-weight: normal;">m/s</span>`;
|
||||
telDevice.innerHTML = msg.car_id || '-';
|
||||
telPos.innerHTML = `${msg.x.toFixed(1)}, ${msg.y.toFixed(1)}`;
|
||||
|
||||
// Measure RTT
|
||||
const currentMs = Date.now();
|
||||
const latency = currentMs - msg.timestamp;
|
||||
telPing.innerHTML = `${latency} <span style="font-size: 0.8rem; font-weight: normal;">ms</span>`;
|
||||
|
||||
if (pingsReceived % 5 === 0) {
|
||||
log(`Telemetry RX: Speed=${msg.speed.toFixed(2)}m/s Car=${msg.car_id || '-'}`, 'in');
|
||||
}
|
||||
pingsReceived++;
|
||||
} else if (msg.type === 'ack') {
|
||||
// Log ACK periodically (every 30 frames) to verify loopback
|
||||
if (pingsReceived % 30 === 0) {
|
||||
log(`ACK RX from server: applied=${msg.applied}`, 'in');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log(`DataChannel Error: ${err.message}`, 'system');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setupVideoChannel(dc) {
|
||||
dc.binaryType = 'arraybuffer';
|
||||
dc.onopen = () => {
|
||||
log(`Video DataChannel ('${dc.label}') is OPEN`, 'system');
|
||||
videoPlaceholder.style.display = 'none';
|
||||
videoFeed.style.display = 'block';
|
||||
};
|
||||
|
||||
dc.onclose = () => {
|
||||
log(`Video DataChannel ('${dc.label}') is CLOSED`, 'system');
|
||||
};
|
||||
|
||||
dc.onmessage = (event) => {
|
||||
// Binary JPEG frames received
|
||||
const blob = new Blob([event.data], { type: 'image/jpeg' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
videoFeed.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
videoFeed.src = url;
|
||||
};
|
||||
}
|
||||
|
||||
function sendControls() {
|
||||
if (dataChannel && dataChannel.readyState === 'open') {
|
||||
const payload = {
|
||||
throttle: currentThrottle,
|
||||
steering: currentSteer,
|
||||
brake: currentBrake,
|
||||
gear: currentGear,
|
||||
buttons: buttons,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
dataChannel.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(state, label) {
|
||||
statusText.innerText = label;
|
||||
statusDot.className = 'status-dot';
|
||||
if (state === 'connected') statusDot.classList.add('connected');
|
||||
if (state === 'connecting') statusDot.classList.add('connecting');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
|
||||
# dotenvx
|
||||
.env.keys
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -0,0 +1,414 @@
|
||||
# For detailed configuration reference documentation, visit:
|
||||
# https://supabase.com/docs/guides/local-development/cli/config
|
||||
# A string used to distinguish different Supabase projects on the same host. Defaults to the
|
||||
# working directory name when running `supabase init`.
|
||||
project_id = "x0gp"
|
||||
|
||||
[api]
|
||||
enabled = true
|
||||
# Port to use for the API URL.
|
||||
port = 54321
|
||||
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
|
||||
# endpoints. `public` and `graphql_public` schemas are included by default.
|
||||
schemas = ["public", "graphql_public"]
|
||||
# Extra schemas to add to the search_path of every request.
|
||||
extra_search_path = ["public", "extensions"]
|
||||
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
|
||||
# for accidental or malicious requests.
|
||||
max_rows = 1000
|
||||
# Controls whether new tables, views, sequences and functions created in the `public` schema by
|
||||
# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`)
|
||||
# without explicit GRANTs. When unset, new entities are NOT auto-exposed, matching the new cloud
|
||||
# default. Set to `true` to keep the legacy behaviour of auto-exposing new entities; this is
|
||||
# deprecated and the field is removed on 2026-10-30 once the always-revoked behaviour is permanent.
|
||||
# auto_expose_new_tables = true
|
||||
|
||||
[api.tls]
|
||||
# Enable HTTPS endpoints locally using a self-signed certificate.
|
||||
enabled = false
|
||||
# Paths to self-signed certificate pair.
|
||||
# cert_path = "../certs/my-cert.pem"
|
||||
# key_path = "../certs/my-key.pem"
|
||||
|
||||
[db]
|
||||
# Port to use for the local database URL.
|
||||
port = 54322
|
||||
# Port used by db diff command to initialize the shadow database.
|
||||
shadow_port = 54320
|
||||
# Maximum amount of time to wait for health check when starting the local database.
|
||||
health_timeout = "2m"
|
||||
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
|
||||
# server_version;` on the remote database to check.
|
||||
major_version = 17
|
||||
|
||||
[db.pooler]
|
||||
enabled = false
|
||||
# Port to use for the local connection pooler.
|
||||
port = 54329
|
||||
# Specifies when a server connection can be reused by other clients.
|
||||
# Configure one of the supported pooler modes: `transaction`, `session`.
|
||||
pool_mode = "transaction"
|
||||
# How many server connections to allow per user/database pair.
|
||||
default_pool_size = 20
|
||||
# Maximum number of client connections allowed.
|
||||
max_client_conn = 100
|
||||
|
||||
# [db.vault]
|
||||
# secret_key = "env(SECRET_VALUE)"
|
||||
|
||||
[db.migrations]
|
||||
# If disabled, migrations will be skipped during a db push or reset.
|
||||
enabled = true
|
||||
# Specifies an ordered list of schema files, directories, or glob patterns that describe your database.
|
||||
# Supports paths relative to supabase directory: "./schemas/*.sql", "./database".
|
||||
schema_paths = []
|
||||
|
||||
[db.seed]
|
||||
# If enabled, seeds the database after migrations during a db reset.
|
||||
enabled = true
|
||||
# Specifies an ordered list of seed files to load during db reset.
|
||||
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
|
||||
sql_paths = ["./seed.sql"]
|
||||
|
||||
[db.network_restrictions]
|
||||
# Enable management of network restrictions.
|
||||
enabled = false
|
||||
# List of IPv4 CIDR blocks allowed to connect to the database.
|
||||
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
|
||||
allowed_cidrs = ["0.0.0.0/0"]
|
||||
# List of IPv6 CIDR blocks allowed to connect to the database.
|
||||
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
|
||||
allowed_cidrs_v6 = ["::/0"]
|
||||
|
||||
# Uncomment to reject non-secure connections to the database.
|
||||
# [db.ssl_enforcement]
|
||||
# enabled = true
|
||||
|
||||
[realtime]
|
||||
enabled = true
|
||||
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
|
||||
# ip_version = "IPv6"
|
||||
# The maximum length in bytes of HTTP request headers. (default: 4096)
|
||||
# max_header_length = 4096
|
||||
|
||||
[studio]
|
||||
enabled = true
|
||||
# Port to use for Supabase Studio.
|
||||
port = 54323
|
||||
# External URL of the API server that frontend connects to.
|
||||
api_url = "http://127.0.0.1"
|
||||
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
|
||||
openai_api_key = "env(OPENAI_API_KEY)"
|
||||
|
||||
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
|
||||
# are monitored, and you can view the emails that would have been sent from the web interface.
|
||||
[local_smtp]
|
||||
enabled = true
|
||||
# Port to use for the email testing server web interface.
|
||||
port = 54324
|
||||
# Uncomment to expose additional ports for testing user applications that send emails.
|
||||
# smtp_port = 54325
|
||||
# pop3_port = 54326
|
||||
# admin_email = "admin@email.com"
|
||||
# sender_name = "Admin"
|
||||
|
||||
[storage]
|
||||
enabled = true
|
||||
# The maximum file size allowed (e.g. "5MB", "500KB").
|
||||
file_size_limit = "50MiB"
|
||||
|
||||
# Uncomment to configure local storage buckets
|
||||
# [storage.buckets.images]
|
||||
# public = false
|
||||
# file_size_limit = "50MiB"
|
||||
# allowed_mime_types = ["image/png", "image/jpeg"]
|
||||
# objects_path = "./images"
|
||||
|
||||
# Allow connections via S3 compatible clients
|
||||
[storage.s3_protocol]
|
||||
enabled = true
|
||||
|
||||
# Image transformation API is available to Supabase Pro plan.
|
||||
# [storage.image_transformation]
|
||||
# enabled = true
|
||||
|
||||
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
|
||||
# This feature is only available on the hosted platform.
|
||||
[storage.analytics]
|
||||
enabled = false
|
||||
max_namespaces = 5
|
||||
max_tables = 10
|
||||
max_catalogs = 2
|
||||
|
||||
# Analytics Buckets is available to Supabase Pro plan.
|
||||
# [storage.analytics.buckets.my-warehouse]
|
||||
|
||||
# Store vector embeddings in S3 for large and durable datasets
|
||||
[storage.vector]
|
||||
enabled = true
|
||||
max_buckets = 10
|
||||
max_indexes = 5
|
||||
|
||||
# Vector Buckets is available to Supabase Pro plan.
|
||||
# [storage.vector.buckets.documents-openai]
|
||||
|
||||
[auth]
|
||||
enabled = true
|
||||
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
||||
# in emails.
|
||||
site_url = "http://127.0.0.1:3000"
|
||||
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
|
||||
# external_url = ""
|
||||
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
||||
additional_redirect_urls = ["https://127.0.0.1:3000"]
|
||||
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
||||
jwt_expiry = 3600
|
||||
# JWT issuer URL. If not set, defaults to auth.external_url.
|
||||
# jwt_issuer = ""
|
||||
# Path to JWT signing key. DO NOT commit your signing keys file to git.
|
||||
# signing_keys_path = "./signing_keys.json"
|
||||
# If disabled, the refresh token will never expire.
|
||||
enable_refresh_token_rotation = true
|
||||
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
|
||||
# Requires enable_refresh_token_rotation = true.
|
||||
refresh_token_reuse_interval = 10
|
||||
# Allow/disallow new user signups to your project.
|
||||
enable_signup = true
|
||||
# Allow/disallow anonymous sign-ins to your project.
|
||||
enable_anonymous_sign_ins = false
|
||||
# Allow/disallow testing manual linking of accounts
|
||||
enable_manual_linking = false
|
||||
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
|
||||
minimum_password_length = 6
|
||||
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
|
||||
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
|
||||
password_requirements = ""
|
||||
|
||||
# Configure passkey sign-ins.
|
||||
# [auth.passkey]
|
||||
# enabled = false
|
||||
|
||||
# Configure WebAuthn relying party settings (required when passkey is enabled).
|
||||
# [auth.webauthn]
|
||||
# rp_display_name = "Supabase"
|
||||
# rp_id = "localhost"
|
||||
# rp_origins = ["http://127.0.0.1:3000"]
|
||||
|
||||
[auth.rate_limit]
|
||||
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
|
||||
email_sent = 2
|
||||
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
|
||||
sms_sent = 30
|
||||
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
|
||||
anonymous_users = 30
|
||||
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
|
||||
token_refresh = 150
|
||||
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
|
||||
sign_in_sign_ups = 30
|
||||
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
|
||||
token_verifications = 30
|
||||
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
|
||||
web3 = 30
|
||||
|
||||
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
|
||||
# [auth.captcha]
|
||||
# enabled = true
|
||||
# provider = "hcaptcha"
|
||||
# secret = ""
|
||||
|
||||
[auth.email]
|
||||
# Allow/disallow new user signups via email to your project.
|
||||
enable_signup = true
|
||||
# If enabled, a user will be required to confirm any email change on both the old, and new email
|
||||
# addresses. If disabled, only the new email is required to confirm.
|
||||
double_confirm_changes = true
|
||||
# If enabled, users need to confirm their email address before signing in.
|
||||
enable_confirmations = false
|
||||
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
|
||||
secure_password_change = false
|
||||
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
|
||||
max_frequency = "1s"
|
||||
# Number of characters used in the email OTP.
|
||||
otp_length = 6
|
||||
# Number of seconds before the email OTP expires (defaults to 1 hour).
|
||||
otp_expiry = 3600
|
||||
|
||||
# Use a production-ready SMTP server
|
||||
# [auth.email.smtp]
|
||||
# enabled = true
|
||||
# host = "smtp.sendgrid.net"
|
||||
# port = 587
|
||||
# user = "apikey"
|
||||
# pass = "env(SENDGRID_API_KEY)"
|
||||
# admin_email = "admin@email.com"
|
||||
# sender_name = "Admin"
|
||||
|
||||
# Uncomment to customize email template
|
||||
# [auth.email.template.invite]
|
||||
# subject = "You have been invited"
|
||||
# content_path = "./supabase/templates/invite.html"
|
||||
|
||||
# Uncomment to customize notification email template
|
||||
# [auth.email.notification.password_changed]
|
||||
# enabled = true
|
||||
# subject = "Your password has been changed"
|
||||
# content_path = "./templates/password_changed_notification.html"
|
||||
|
||||
[auth.sms]
|
||||
# Allow/disallow new user signups via SMS to your project.
|
||||
enable_signup = false
|
||||
# If enabled, users need to confirm their phone number before signing in.
|
||||
enable_confirmations = false
|
||||
# Template for sending OTP to users
|
||||
template = "Your code is {{ `{{ .Code }}` }}"
|
||||
# Controls the minimum amount of time that must pass before sending another sms otp.
|
||||
max_frequency = "5s"
|
||||
|
||||
# Use pre-defined map of phone number to OTP for testing.
|
||||
# [auth.sms.test_otp]
|
||||
# 4152127777 = "123456"
|
||||
|
||||
# Configure logged in session timeouts.
|
||||
# [auth.sessions]
|
||||
# Force log out after the specified duration.
|
||||
# timebox = "24h"
|
||||
# Force log out if the user has been inactive longer than the specified duration.
|
||||
# inactivity_timeout = "8h"
|
||||
|
||||
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
|
||||
# [auth.hook.before_user_created]
|
||||
# enabled = true
|
||||
# uri = "pg-functions://postgres/auth/before-user-created-hook"
|
||||
|
||||
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
|
||||
# [auth.hook.custom_access_token]
|
||||
# enabled = true
|
||||
# uri = "pg-functions://<database>/<schema>/<hook_name>"
|
||||
|
||||
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
|
||||
[auth.sms.twilio]
|
||||
enabled = false
|
||||
account_sid = ""
|
||||
message_service_sid = ""
|
||||
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
|
||||
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
|
||||
|
||||
# Multi-factor-authentication is available to Supabase Pro plan.
|
||||
[auth.mfa]
|
||||
# Control how many MFA factors can be enrolled at once per user.
|
||||
max_enrolled_factors = 10
|
||||
|
||||
# Control MFA via App Authenticator (TOTP)
|
||||
[auth.mfa.totp]
|
||||
enroll_enabled = false
|
||||
verify_enabled = false
|
||||
|
||||
# Configure MFA via Phone Messaging
|
||||
[auth.mfa.phone]
|
||||
enroll_enabled = false
|
||||
verify_enabled = false
|
||||
otp_length = 6
|
||||
template = "Your code is {{ `{{ .Code }}` }}"
|
||||
max_frequency = "5s"
|
||||
|
||||
# Configure MFA via WebAuthn
|
||||
# [auth.mfa.web_authn]
|
||||
# enroll_enabled = true
|
||||
# verify_enabled = true
|
||||
|
||||
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
|
||||
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
|
||||
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
|
||||
[auth.external.apple]
|
||||
enabled = false
|
||||
client_id = ""
|
||||
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
|
||||
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
|
||||
# Overrides the default auth callback URL derived from auth.external_url.
|
||||
redirect_uri = ""
|
||||
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
|
||||
# or any other third-party OIDC providers.
|
||||
url = ""
|
||||
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
|
||||
skip_nonce_check = false
|
||||
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
|
||||
email_optional = false
|
||||
|
||||
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
|
||||
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
|
||||
[auth.web3.solana]
|
||||
enabled = false
|
||||
|
||||
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.firebase]
|
||||
enabled = false
|
||||
# project_id = "my-firebase-project"
|
||||
|
||||
# Use Auth0 as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.auth0]
|
||||
enabled = false
|
||||
# tenant = "my-auth0-tenant"
|
||||
# tenant_region = "us"
|
||||
|
||||
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.aws_cognito]
|
||||
enabled = false
|
||||
# user_pool_id = "my-user-pool-id"
|
||||
# user_pool_region = "us-east-1"
|
||||
|
||||
# Use Clerk as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.clerk]
|
||||
enabled = false
|
||||
# Obtain from https://clerk.com/setup/supabase
|
||||
# domain = "example.clerk.accounts.dev"
|
||||
|
||||
# OAuth server configuration
|
||||
[auth.oauth_server]
|
||||
# Enable OAuth server functionality
|
||||
enabled = false
|
||||
# Path for OAuth consent flow UI
|
||||
authorization_url_path = "/oauth/consent"
|
||||
# Allow dynamic client registration
|
||||
allow_dynamic_registration = false
|
||||
|
||||
[edge_runtime]
|
||||
enabled = true
|
||||
# Supported request policies: `oneshot`, `per_worker`.
|
||||
# `per_worker` (default) — enables hot reload during local development.
|
||||
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
|
||||
policy = "per_worker"
|
||||
# Port to attach the Chrome inspector for debugging edge functions.
|
||||
inspector_port = 8083
|
||||
# The Deno major version to use.
|
||||
deno_version = 2
|
||||
|
||||
# [edge_runtime.secrets]
|
||||
# secret_key = "env(SECRET_VALUE)"
|
||||
|
||||
[analytics]
|
||||
enabled = true
|
||||
port = 54327
|
||||
# Configure one of the supported backends: `postgres`, `bigquery`.
|
||||
backend = "postgres"
|
||||
|
||||
# Experimental features may be deprecated any time
|
||||
[experimental]
|
||||
# Configures Postgres storage engine to use OrioleDB (S3)
|
||||
orioledb_version = ""
|
||||
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
|
||||
s3_host = "env(S3_HOST)"
|
||||
# Configures S3 bucket region, eg. us-east-1
|
||||
s3_region = "env(S3_REGION)"
|
||||
# Configures AWS_ACCESS_KEY_ID for S3 bucket
|
||||
s3_access_key = "env(S3_ACCESS_KEY)"
|
||||
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
|
||||
s3_secret_key = "env(S3_SECRET_KEY)"
|
||||
|
||||
# pg-delta is the schema diff engine for db diff / db pull / db remote commit.
|
||||
# Set enabled = false to fall back to the legacy migra engine.
|
||||
[experimental.pgdelta]
|
||||
enabled = true
|
||||
# Directory under `supabase/` where declarative files are written.
|
||||
# declarative_schema_path = "./database"
|
||||
# JSON string passed through to pg-delta SQL formatting.
|
||||
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
|
||||
@@ -0,0 +1,265 @@
|
||||
-- 20260717000000_schema.sql — Consolidated schema for x0gp catalog, races, and drivers.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tracks & Waypoints
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tracks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
author_id TEXT NOT NULL DEFAULT 'system',
|
||||
visibility TEXT NOT NULL DEFAULT 'system' CHECK (visibility IN ('system', 'public', 'private')),
|
||||
scale INT NOT NULL DEFAULT 27,
|
||||
length_m DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
width_m DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
lane_width_m DOUBLE PRECISION NOT NULL DEFAULT 0.20,
|
||||
surface TEXT NOT NULL DEFAULT 'carpet' CHECK (surface IN ('carpet', 'tile', 'wood', 'paper', 'mixed')),
|
||||
best_lap_ms BIGINT NOT NULL DEFAULT 0,
|
||||
best_lap_holder TEXT NOT NULL DEFAULT '',
|
||||
bounds_min_x DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
bounds_min_y DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
bounds_max_x DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
bounds_max_y DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
created_ms BIGINT NOT NULL,
|
||||
updated_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tracks_visibility_idx ON tracks (visibility);
|
||||
CREATE INDEX IF NOT EXISTS tracks_author_idx ON tracks (author_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS track_waypoints (
|
||||
track_id TEXT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
seq INT NOT NULL,
|
||||
x DOUBLE PRECISION NOT NULL,
|
||||
y DOUBLE PRECISION NOT NULL,
|
||||
heading_rad DOUBLE PRECISION NOT NULL,
|
||||
speed_ms DOUBLE PRECISION NOT NULL,
|
||||
curvature DOUBLE PRECISION NOT NULL,
|
||||
PRIMARY KEY (track_id, seq)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS track_tags (
|
||||
track_id TEXT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (track_id, tag)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS track_tags_tag_idx ON track_tags (tag);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Cars
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cars (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL DEFAULT 'system',
|
||||
visibility TEXT NOT NULL DEFAULT 'system' CHECK (visibility IN ('system', 'public', 'private')),
|
||||
scale INT NOT NULL DEFAULT 27,
|
||||
|
||||
length_mm DOUBLE PRECISION NOT NULL,
|
||||
width_mm DOUBLE PRECISION NOT NULL,
|
||||
height_mm DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
weight_g DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
|
||||
chassis_model TEXT NOT NULL DEFAULT '',
|
||||
chassis_material TEXT NOT NULL DEFAULT '',
|
||||
chassis_printed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
chassis_wheelbase_mm DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
chassis_track_mm DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
|
||||
motor_kind TEXT NOT NULL DEFAULT 'brushed',
|
||||
motor_class TEXT NOT NULL DEFAULT '',
|
||||
motor_kv INT NOT NULL DEFAULT 0,
|
||||
motor_power_w DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
|
||||
battery_voltage_v DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
battery_capacity_mah INT NOT NULL DEFAULT 0,
|
||||
battery_cells INT NOT NULL DEFAULT 0,
|
||||
battery_chemistry TEXT NOT NULL DEFAULT 'li-po',
|
||||
|
||||
drive TEXT NOT NULL DEFAULT '2WD' CHECK (drive IN ('2WD', '4WD')),
|
||||
|
||||
top_speed_ms DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
color_hex TEXT NOT NULL DEFAULT '#ff6b1a',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
total_distance_m DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
total_races INT NOT NULL DEFAULT 0,
|
||||
total_laps INT NOT NULL DEFAULT 0,
|
||||
best_lap_ms BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
created_ms BIGINT NOT NULL,
|
||||
updated_ms BIGINT NOT NULL,
|
||||
device_id INT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cars_visibility_idx ON cars (visibility);
|
||||
CREATE INDEX IF NOT EXISTS cars_owner_idx ON cars (owner_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Clans & Drivers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clans (
|
||||
id TEXT PRIMARY KEY,
|
||||
tag TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
created_ms BIGINT NOT NULL,
|
||||
updated_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS clans_tag_idx ON clans (tag);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS drivers (
|
||||
id TEXT PRIMARY KEY,
|
||||
nickname TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
clan_id TEXT REFERENCES clans(id) ON DELETE SET NULL,
|
||||
created_ms BIGINT NOT NULL,
|
||||
updated_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS drivers_nickname_idx ON drivers (nickname);
|
||||
CREATE INDEX IF NOT EXISTS drivers_clan_idx ON drivers (clan_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Races & Race Drivers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS races (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
track_id TEXT NOT NULL DEFAULT '',
|
||||
max_cars INT NOT NULL,
|
||||
laps INT NOT NULL,
|
||||
time_limit_s INT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'lobby' CHECK (status IN ('lobby', 'countdown', 'racing', 'finished', 'cancelled')),
|
||||
created_ms BIGINT NOT NULL,
|
||||
started_ms BIGINT NOT NULL DEFAULT 0,
|
||||
finished_ms BIGINT NOT NULL DEFAULT 0,
|
||||
duration_ms BIGINT NOT NULL DEFAULT 0,
|
||||
total_laps INT NOT NULL DEFAULT 0,
|
||||
total_drivers INT NOT NULL DEFAULT 0,
|
||||
updated_ms BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS races_status_finished_idx ON races (status, finished_ms DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS races_started_idx ON races (status, started_ms DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS races_track_idx ON races (track_id);
|
||||
CREATE INDEX IF NOT EXISTS races_status_idx ON races (status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS race_drivers (
|
||||
race_id TEXT NOT NULL REFERENCES races(id) ON DELETE CASCADE,
|
||||
driver_id TEXT NOT NULL,
|
||||
slot INT NOT NULL DEFAULT 0,
|
||||
joined_ms BIGINT NOT NULL,
|
||||
total_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
best_lap_ms BIGINT NOT NULL DEFAULT 0,
|
||||
position INT,
|
||||
PRIMARY KEY (race_id, driver_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS race_drivers_driver_idx ON race_drivers (driver_id);
|
||||
CREATE INDEX IF NOT EXISTS race_drivers_podium_idx ON race_drivers (race_id, total_time_ms ASC, position ASC) WHERE total_time_ms > 0;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Race Plans & Queue
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS race_plans (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
track_id TEXT NOT NULL DEFAULT 'default',
|
||||
max_cars INT NOT NULL,
|
||||
laps INT NOT NULL DEFAULT 0,
|
||||
time_limit_s INT NOT NULL DEFAULT 0,
|
||||
start_at_ms BIGINT NOT NULL,
|
||||
interval_s INT NOT NULL DEFAULT 0, -- 0 = single-shot
|
||||
count INT NOT NULL DEFAULT 0, -- 0 = repeat forever
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_ms BIGINT NOT NULL,
|
||||
updated_ms BIGINT NOT NULL,
|
||||
next_fire_ms BIGINT NOT NULL, -- last materialised fire time
|
||||
fires_done INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS race_plans_start_idx ON race_plans (start_at_ms ASC, id ASC);
|
||||
CREATE INDEX IF NOT EXISTS race_plans_enabled_idx ON race_plans (enabled, next_fire_ms);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS race_queue (
|
||||
driver_id TEXT NOT NULL,
|
||||
race_id TEXT NOT NULL,
|
||||
plan_id TEXT NOT NULL DEFAULT '',
|
||||
enqueued_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (driver_id, race_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS race_queue_race_idx ON race_queue (race_id);
|
||||
CREATE INDEX IF NOT EXISTS race_queue_driver_idx ON race_queue (driver_id, enqueued_ms);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Lobby Drivers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lobby_drivers (
|
||||
driver_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
nickname TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
clan_id TEXT NOT NULL DEFAULT '',
|
||||
clan_tag TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'idle' CHECK (status IN ('idle', 'racing', 'offline')),
|
||||
current_race_id TEXT NOT NULL DEFAULT '',
|
||||
connected_ms BIGINT NOT NULL,
|
||||
last_seen_ms BIGINT NOT NULL,
|
||||
car_id TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS lobby_drivers_status_idx ON lobby_drivers (status);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Track Calendar
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS track_calendar (
|
||||
id SERIAL PRIMARY KEY,
|
||||
track_id TEXT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
start_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
end_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
CHECK (start_at < end_at)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS track_calendar_dates_idx ON track_calendar (start_at, end_at);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Go Migration Metadata (Bridges Go server startup migrations and Supabase)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO schema_migrations (version) VALUES
|
||||
('001_init.sql'),
|
||||
('002_seed.sql'),
|
||||
('003_f1_seed.sql'),
|
||||
('004_monaco_rescale.sql'),
|
||||
('005_races.sql'),
|
||||
('006_drop_host.sql'),
|
||||
('007_podium.sql'),
|
||||
('008_drivers_clans.sql'),
|
||||
('009_live_persistence.sql'),
|
||||
('010_unify_races.sql'),
|
||||
('011_drop_driver_ids.sql'),
|
||||
('012_normalize_race_results.sql'),
|
||||
('013_driver_device.sql'),
|
||||
('014_track_calendar.sql'),
|
||||
('015_driver_car_id.sql')
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
-- seed.sql — Initial system catalog (tracks + cars) for Supabase.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Monaco (Rescaled)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('monaco', 'Monaco', 'Circuit de Monaco. Narrow street circuit, tight hairpins, no room for error.', 'system', 'system', 27,
|
||||
21.87, 6.46, 0.18, 'tile', 0, 'Verstappen',
|
||||
-3.231, -2.684, 3.231, 2.684,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 0, -3.2064, 1.0454, -1.4710, 0.632, -0.1988) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 1, -3.1387, 0.5857, -1.3926, 0.632, -0.1787) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 2, -3.0418, 0.1312, -1.3043, 0.632, -0.2544) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 3, -2.8946, -0.3084, -1.1557, 0.632, -0.4431) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 4, -2.6693, -0.7141, -0.8629, 0.632, -1.0875) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 5, -2.3517, -0.9426, -0.1919, 0.632, -1.2844) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 6, -1.8935, -0.8648, 0.1182, 0.632, -0.2237) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 7, -1.4302, -0.8332, 0.0660, 0.632, 0.0475) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 8, -0.9667, -0.8035, 0.0743, 0.632, 0.0756) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 9, -0.5052, -0.7644, -0.0045, 0.632, 0.0806) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 10, -0.0425, -0.8077, -0.0000, 0.632, -0.0425) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 11, 0.4197, -0.7644, 0.0351, 0.632, 0.3650) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 12, 0.8809, -0.7753, -0.3862, 0.632, 1.3887) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 13, 1.1985, -1.0811, -1.2440, 0.632, 1.4550) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 14, 1.1372, -1.5315, -1.5878, 0.632, -0.2144) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 15, 1.1839, -1.9468, -1.0284, 0.632, -1.2681) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 16, 1.5551, -2.2249, -0.6162, 0.632, -0.4763) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 17, 1.9414, -2.4832, -0.5226, 0.632, -0.1756) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 18, 2.3526, -2.6842, -0.4546, 0.632, -0.1494) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 19, 2.3526, -2.6842, 1.2476, 0.632, -0.0225) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 20, 2.4622, -2.3570, 1.2545, 0.632, 0.1775) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 21, 2.5976, -1.9356, 0.5751, 0.632, 34.1925) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 22, 2.6154, -2.2577, -1.0415, 0.632, -0.8400) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 23, 2.9239, -2.4933, 0.0053, 0.632, -3.1975) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 24, 3.2311, -2.2544, 1.4159, 0.632, -2.7456) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 25, 3.0259, -1.8399, 2.1127, 0.632, -0.6488) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 26, 2.7546, -1.4629, 2.2528, 0.632, -0.2769) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 27, 2.4413, -1.1199, 2.3708, 0.632, -0.3044) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 28, 2.0895, -0.8170, 2.5300, 0.632, -0.3825) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 29, 1.6852, -0.5897, 2.7255, 0.632, -0.4019) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 30, 1.2447, -0.4437, 2.9031, 0.632, -0.3538) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 31, 0.7862, -0.3711, 3.0547, 0.632, -0.3044) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 32, 0.3218, -0.3632, -3.0973, 0.632, -0.0975) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 33, -0.1396, -0.4121, -3.1387, 0.632, -0.0394) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 34, -0.5818, -0.3659, -3.0594, 0.632, -0.2375) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 35, -1.0290, -0.4854, -2.9319, 0.632, -0.0950) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 36, -1.4880, -0.5588, -2.9664, 0.632, -0.2500) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 37, -1.9441, -0.6474, 3.1052, 0.632, 0.8825) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 38, -2.3631, -0.5269, 2.4971, 0.632, 1.3600) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 39, -2.6181, -0.1409, 1.9547, 0.632, 0.9481) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 40, -2.7014, 0.3108, 1.5706, 0.632, 0.3788) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 41, -2.6179, 0.7633, 1.6025, 0.632, -0.3425) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 42, -2.7299, 1.2092, 1.8788, 0.632, -0.0725) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 43, -2.8930, 1.6280, 1.6659, 0.632, 0.7706) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 44, -2.8134, 2.0840, 1.2045, 0.632, 0.2162) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 45, -2.5685, 2.4739, 1.5682, 0.632, -2.4475) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 46, -2.8118, 2.6842, -3.0638, 0.632, -4.0544) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 47, -3.1281, 2.4303, -2.0706, 0.632, -1.2500) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 48, -3.2004, 1.9728, -1.6822, 0.632, -0.4706) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 49, -3.2311, 1.5093, -1.5774, 0.632, -0.2275) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 50, -3.2064, 1.0454, -1.4710, 0.632, -0.1988) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'f1') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'street') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'tight') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'slow') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'monaco') ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Barcelona-Catalunya
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('barcelona-catalunya', 'Barcelona-Catalunya', 'Circuit de Barcelona-Catalunya. Mix of high-speed corners and technical chicanes.', 'system', 'system', 27,
|
||||
3.75, 2.30, 0.18, 'tile', 0, 'Verstappen',
|
||||
0.3, -2.7, 4.05, -0.4,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 0, 0.3, -0.4, -0, 4.5, 0) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 1, 1.4, -0.4, -0, 4.5, 0) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 2, 1.7, -0.65, -1.5707963267948966, 2.2, 1.6) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 3, 1.5, -0.95, -3.141592653589793, 2, 2) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 4, 1.2, -1.1, -3.4415926535897934, 2.6, 1) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 5, 0.95, -1, -4.0415926535897935, 2.6, 1) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 6, 0.8, -1.2, 1.5707963267948966, 2.4, 1.2) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 7, 1.05, -1.5, -0, 3.2, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 8, 1.3, -1.65, -1.5707963267948966, 2.4, 1) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 9, 1.15, -1.85, -3.141592653589793, 2.4, 1) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 10, 1.4, -2, -2.641592653589793, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 11, 1.7, -2, -2.141592653589793, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 12, 1.95, -2.2, -1.5707963267948966, 2.8, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 13, 1.65, -2.5, -0.3, 2.8, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 14, 2.2, -2.7, -0.7, 3.2, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 15, 2.8, -2.7, -1.1, 3.4, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 16, 3.1, -2.5, -1.9707963267948967, 2.2, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 17, 2.95, -2.2, -3.541592653589793, 2.2, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 18, 3.3, -1.9, -0, 4, 0) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 19, 4, -1.9, -0, 4, 0) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 20, 4.05, -1.6, 1.5707963267948966, 2.4, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 21, 3.8, -1.4, -3.141592653589793, 2.4, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 22, 3.5, -1.5, -3.9269908169872414, 2.6, 1) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 23, 3.1, -1.25, 1.9707963267948967, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 24, 2.7, -1, 2.4707963267948965, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 25, 2.3, -0.7, -3.541592653589793, 3.4, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 26, 1.6, -0.5, -3.3415926535897933, 3.6, 0.4) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('barcelona-catalunya', 27, 0.9, -0.42, -0, 4, 0) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('barcelona-catalunya', 'f1') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('barcelona-catalunya', 'balanced') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('barcelona-catalunya', 'testing') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('barcelona-catalunya', 'barcelona') ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Austria (Red Bull Ring)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('austria-redbull-ring', 'Austria (Red Bull Ring)', 'Red Bull Ring. Short, fast, three straights separated by hairpins.', 'system', 'system', 27,
|
||||
3.55, 2.0, 0.18, 'wood', 0, 'Verstappen',
|
||||
0.3, -2.4, 3.85, -0.4,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 0, 0.3, -0.4, -0, 4.8, 0) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 1, 1.6, -0.4, -0, 4.8, 0) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 2, 1.85, -0.65, -1.5707963267948966, 2, 1.8) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 3, 1.6, -0.95, -3.141592653589793, 2, 1.8) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 4, 1.3, -1, -3.641592653589793, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 5, 1.05, -1.2, 1.5707963267948966, 2.6, 1) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 6, 1.4, -1.4, -0, 3.2, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 7, 1.85, -1.4, -1.7707963267948965, 3.2, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 8, 2.1, -1.65, -3.141592653589793, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 9, 2, -1.85, 1.5707963267948966, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 10, 2.25, -2.05, -0, 2, 1.8) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 11, 2.4, -2, -1.5707963267948966, 2, 1.8) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 12, 2.4, -2.2, -3.141592653589793, 2, 1.8) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 13, 2.2, -2.3, 1.5707963267948966, 2, 1.8) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 14, 2.55, -2.4, 0.2, 3.4, 0.4) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 15, 3, -2.4, -0, 4, 0) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 16, 3.5, -2.4, -0, 4.5, 0) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 17, 3.85, -2.15, -1.7707963267948965, 3.4, 0.6) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 18, 3.6, -1.8, -3.141592653589793, 3, 0.8) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 19, 3.25, -1.85, -3.741592653589793, 3, 0.8) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 20, 3, -1.6, 1.9707963267948967, 3.2, 0.6) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 21, 2.6, -1.4, 3.141592653589793, 3.4, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 22, 2, -1.2, -2.641592653589793, 3.6, 0.4) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 23, 1.4, -0.8, -2.9415926535897934, 3.8, 0.3) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('austria-redbull-ring', 24, 0.8, -0.5, -0, 4.2, 0.2) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('austria-redbull-ring', 'f1') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('austria-redbull-ring', 'short') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('austria-redbull-ring', 'fast') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('austria-redbull-ring', 'austria') ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Great Britain (Silverstone)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('great-britain-silverstone', 'Great Britain (Silverstone)', 'Silverstone Circuit. Fast, flowing corners; home of British motorsport.', 'system', 'system', 27,
|
||||
3.55, 2.4, 0.18, 'carpet', 0, 'Hamilton',
|
||||
0.3, -2.65, 3.85, -0.25,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 0, 0.3, -0.3, -0, 5, 0) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 1, 2.4, -0.3, -0, 5, 0) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 2, 2.8, -0.45, -1.5707963267948966, 3, 0.8) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 3, 2.6, -0.8, -3.141592653589793, 3.4, 0.6) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 4, 2.95, -1.05, -2.0707963267948966, 3.2, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 5, 3.3, -1.3, -0, 2.2, 1.6) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 6, 3.6, -1.15, 1.5707963267948966, 2.2, 1.6) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 7, 3.6, -0.8, 3.141592653589793, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 8, 3.85, -0.55, 1.5707963267948966, 2.6, 1) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 9, 3.55, -0.25, -0, 2.4, 1) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 10, 3.25, -0.25, -1.5707963267948966, 2.4, 1) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 11, 3.05, -0.55, -1.8707963267948966, 3.4, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 12, 2.85, -0.45, -3.141592653589793, 4, 0.3) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 13, 1.8, -0.3, -0, 4.6, 0) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 14, 1.2, -0.45, 1.7707963267948965, 3.8, 0.4) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 15, 1, -0.85, 3.541592653589793, 3.6, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 16, 0.7, -1.15, -1.8707963267948966, 2.4, 1.2) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 17, 0.5, -0.9, -0, 2.6, 0.9) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 18, 0.7, -0.65, 1.5707963267948966, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 19, 0.45, -0.45, 3.541592653589793, 2.6, 1) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 20, 0.75, -1, -1.1707963267948966, 2.4, 1) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 21, 0.5, -1.5, 3.541592653589793, 2.6, 0.8) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 22, 0.3, -1.15, 1.5707963267948966, 2.4, 1) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 23, 0.7, -1.6, -0.7853981633974483, 3.4, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 24, 2, -1.95, -1.9707963267948967, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 25, 3, -2.4, -3.3415926535897933, 3.4, 0.4) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 26, 2.4, -2.65, 1.8707963267948966, 4, 0.3) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 27, 1.4, -2.6, 3.141592653589793, 4.4, 0.2) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 28, 0.5, -2.3, 2.8415926535897933, 4.8, 0.2) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('great-britain-silverstone', 29, 0.3, -1.7, 1.2707963267948965, 5, 0.1) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('great-britain-silverstone', 'f1') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('great-britain-silverstone', 'fast') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('great-britain-silverstone', 'flowing') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('great-britain-silverstone', 'silverstone') ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Belgium (Spa-Francorchamps)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('belgium-spa', 'Belgium (Spa-Francorchamps)', 'Spa-Francorchamps. Long, fast, legendary Eau Rouge / Raidillon.', 'system', 'system', 27,
|
||||
3.85, 2.25, 0.18, 'carpet', 0, 'Verstappen',
|
||||
0.4, -2.75, 4.25, -0.5,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 0, 0.4, -0.5, -0, 4.8, 0) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 1, 1.6, -0.5, -0, 4.8, 0) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 2, 1.95, -0.6, -0.3, 4.2, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 3, 2.2, -0.95, -0.9, 4, 0.6) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 4, 2.1, -1.3, -3.541592653589793, 4, 0.6) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 5, 2.7, -1.5, -0, 5, 0) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 6, 3.5, -1.5, -0, 5, 0) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 7, 3.85, -1.3, 1.5707963267948966, 2.4, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 8, 3.6, -1.1, -3.4415926535897934, 2.6, 1.2) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 9, 3.3, -1.25, -4.0415926535897935, 2.6, 1.2) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 10, 3.5, -1.55, -1.5707963267948966, 3.6, 0.4) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 11, 3.25, -1.85, -0, 2.2, 1.6) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 12, 3.05, -1.7, 1.5707963267948966, 2.4, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 13, 3.25, -1.5, -0, 3, 0.7) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 14, 3.7, -1.65, -1.8707963267948966, 3.4, 0.5) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 15, 4.1, -2, -2.1707963267948966, 4, 0.3) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 16, 4.25, -2.4, -3.541592653589793, 4.6, 0.3) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 17, 4, -2.7, 1.8707963267948966, 2.4, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 18, 3.7, -2.55, -3.141592653589793, 2.4, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 19, 3.55, -2.75, -3.8415926535897933, 2.4, 1.4) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 20, 3.05, -2.7, 1.5707963267948966, 2, 1.8) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 21, 2.85, -2.45, -0, 2, 1.8) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 22, 2, -2.2, -2.8415926535897933, 3.6, 0.3) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 23, 1.2, -1.6, -2.541592653589793, 4, 0.2) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('belgium-spa', 24, 0.6, -1.05, -2.8415926535897933, 4.4, 0.2) ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('belgium-spa', 'f1') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('belgium-spa', 'long') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('belgium-spa', 'fast') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('belgium-spa', 'spa') ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Cars Catalog
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO cars (id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('f1-2024-redbull-rb20', 'Oracle Red Bull Racing RB20', 'system', 'system', 24,
|
||||
238, 83, 40, 33,
|
||||
'RB20 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
|
||||
'turbo-hybrid', 'F1 PU', 0, 746000,
|
||||
0, 0, 0, 'li-ion MGU-K',
|
||||
'2WD', 95, '#1e3a8a', '', TRUE,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO cars (id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('f1-2024-ferrari-sf24', 'Scuderia Ferrari SF-24', 'system', 'system', 24,
|
||||
238, 83, 40, 33,
|
||||
'SF-24 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
|
||||
'turbo-hybrid', 'F1 PU 066/12', 0, 746000,
|
||||
0, 0, 0, 'li-ion MGU-K',
|
||||
'2WD', 94, '#dc2626', '', TRUE,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO cars (id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('f1-2024-mclaren-mcl38', 'McLaren MCL38', 'system', 'system', 24,
|
||||
238, 83, 40, 33,
|
||||
'MCL38 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
|
||||
'turbo-hybrid', 'F1 PU Mercedes-derived', 0, 746000,
|
||||
0, 0, 0, 'li-ion MGU-K',
|
||||
'2WD', 94, '#ea580c', '', TRUE,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO cars (id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('f1-2024-mercedes-w15', 'Mercedes-AMG W15', 'system', 'system', 24,
|
||||
238, 83, 40, 33,
|
||||
'W15 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
|
||||
'turbo-hybrid', 'F1 PU M15', 0, 746000,
|
||||
0, 0, 0, 'li-ion MGU-K',
|
||||
'2WD', 94, '#06b6d4', '', TRUE,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO cars (id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('f1-2024-astonmartin-amr24', 'Aston Martin Aramco AMR24', 'system', 'system', 24,
|
||||
238, 83, 40, 33,
|
||||
'AMR24 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
|
||||
'turbo-hybrid', 'F1 PU Mercedes-derived', 0, 746000,
|
||||
0, 0, 0, 'li-ion MGU-K',
|
||||
'2WD', 94, '#16a34a', '', TRUE,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
Reference in New Issue
Block a user