feat(server): initial implementation

This commit captures the full server code accumulated across
several development sessions. It includes the following layers,
applied in roughly this order during development:

1. Base infrastructure
   - cmd/poc-server HTTP+WebSocket server, /health, /stats
   - internal/transport wire types (Envelope + all message types)
   - internal/catalog, internal/realtime, internal/stats
   - internal/storage/postgres with migrations 001-004
   - .env, .env.example, docker-compose, Dockerfile, scripts/

2. Swagger documentation
   - go get github.com/swaggo/http-swagger
   - swag init produces docs/docs.go
   - /swagger/index.html UI, /swagger/doc.json spec
   - annotations on health/stats/ws and catalog handlers

3. Races API
   - internal/races/{store,service,keyset,types}.go
   - migration 005_races.sql: finished_races, race_plans, race_queue
   - GET /api/races with keyset pagination, status filter
   - GET /api/races/upcoming
   - POST /api/races/queue/join, GET, DELETE
   - POST/GET /api/races/plans, DELETE /{id}
   - lobby.RaceMeta simplification (no host_id/host_name)

4. Races seeder
   - internal/races/seed with deterministic generator
   - --seed-races / --reset CLI flags in main.go
   - 30 finished + 5 live + 5 plans + 4 queue entries

5. Drivers and clans
   - internal/drivers, internal/clans (CRUD, validation)
   - migration 008_drivers_clans.sql
   - /api/clans and /api/drivers endpoints
   - 3-letter uppercase nickname/tag validation
   - lobby.DriverMeta: Nickname, AvatarURL, ClanID, ClanTag

6. Podium
   - internal/transport.RacePodiumEntry
   - lobby.SetDriverProfile for in-memory metadata sync
   - migration 007_podium.sql (podium JSONB column)
   - seeder populates top-3 per finished race

7. Live races persistence
   - migration 009_live_persistence.sql: live_races,
     live_race_drivers, lobby_drivers
   - internal/races/live_store.go: LiveStore with write-side
     mirror for lobby.Service mutations
   - Service.collectLive and Upcoming read from Postgres
   - main.go RestoreFromDB rehydrates lobby on startup

8. Unify live + finished into one races table
   - migration 010_unify_races.sql: rename finished_races to
     races, expand status CHECK, merge live rows
   - PgStore now hosts both write paths; LiveStore is a
     thin facade implementing lobby.Persistence
   - seeder resetAll drops only finished/cancelled rows and
     race_plans / race_queue / lobby_drivers

Each layer is consistent on its own; cross-layer changes are
visible in the file history. A future refactor may split this
commit into the per-stage boundaries listed above.
This commit is contained in:
x0gp
2026-06-22 22:01:09 +04:00
commit 978d36c505
71 changed files with 23500 additions and 0 deletions
@@ -0,0 +1,614 @@
// Package seeddefs holds the canonical seed definitions for tracks and
// cars. It lives in its own package (no postgres dependencies) so both
// the catalog tests and the scripts/genseed SQL generator can consume
// the same source-of-truth data.
package seeddefs
import "math"
// Default tracks and cars shipped with the PoC. All entries are
// visibility=system and therefore immutable from the API.
//
// Tracks: five real F1 circuits (2024 calendar), simplified to fit a
// 4.5 × 3.2 m bounding box at 1/27 scale. Each track keeps a
// recognisable shape and characteristic corners.
//
// Cars: five real F1 2024 teams. Length / width / weight are real
// (1/24 scale), power figures are real (full-scale 1.6L V6 hybrid
// turbo, ~1000 hp combined MGU-K + ICE).
func DefaultTracks() []Track {
return []Track{
Monaco(),
BarcelonaCatalunya(),
AustriaRedBullRing(),
GreatBritainSilverstone(),
BelgiumSpa(),
}
}
func DefaultCars() []Car {
return []Car{
RedBullRB20(),
FerrariSF24(),
McLarenMCL38(),
MercedesW15(),
AstonMartinAMR24(),
}
}
// ---------------------------------------------------------------------------
// Tracks
//
// Coordinates: lower-left origin, metres. Bounds must fit inside a
// 4.5 × 3.2 m room.
//
// Conventions:
// - closed loop, samples[i] and samples[0] are not duplicated
// - speed_ms is a suggested cornering speed (lower = tighter)
// - curvature = 1/radius; 0 = straight
// ---------------------------------------------------------------------------
// Monaco — narrow street circuit, hairpins, tight corners.
// Length ≈ 3.337 km IRL → simplified to 4.0 × 2.6 m.
func Monaco() Track {
const (
lengthM = 4.0
widthM = 2.6
)
// Famous corners: Sainte-Devote, Casino Square, Mirabeau, Loews hairpin,
// Portier, Tunnel, Nouvelle chicane, Tabac, Swimming pool, Rascasse, Anthony Noghes.
// Lower speeds through Monaco → 1.8..3.2 m/s.
wps := []Waypoint{
// Start/finish straight (Sainte-Devote approach).
{X: 0.4, Y: 2.2, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0},
{X: 1.1, Y: 2.2, HeadingRad: 0, SpeedMs: 3.0, Curvature: 0},
// Sainte-Devote (right kink).
{X: 1.5, Y: 2.05, HeadingRad: -0.4, SpeedMs: 2.4, Curvature: 1.2},
{X: 1.7, Y: 1.85, HeadingRad: -0.9, SpeedMs: 2.2, Curvature: 1.8},
// Up the hill to Casino / Massenet.
{X: 1.55, Y: 1.55, HeadingRad: -1.6, SpeedMs: 2.0, Curvature: 1.4},
{X: 1.25, Y: 1.4, HeadingRad: -2.4, SpeedMs: 2.1, Curvature: 1.2},
// Casino Square (slow left).
{X: 0.95, Y: 1.55, HeadingRad: math.Pi, SpeedMs: 1.9, Curvature: 1.6},
// Mirabeau hairpin (right).
{X: 0.75, Y: 1.85, HeadingRad: 2.0, SpeedMs: 1.8, Curvature: 2.2},
// Loews hairpin (tight left).
{X: 0.95, Y: 2.15, HeadingRad: 2.6, SpeedMs: 1.8, Curvature: 2.6},
{X: 1.2, Y: 2.4, HeadingRad: -2.8, SpeedMs: 2.1, Curvature: 1.4},
// Portier (right kink onto the straight).
{X: 1.6, Y: 2.5, HeadingRad: -3.0, SpeedMs: 2.4, Curvature: 1.0},
// Tunnel straight.
{X: 2.6, Y: 2.5, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0},
{X: 3.4, Y: 2.5, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0},
// Nouvelle chicane (fast left-right).
{X: 3.6, Y: 2.25, HeadingRad: -0.5, SpeedMs: 2.6, Curvature: 1.5},
{X: 3.55, Y: 1.95, HeadingRad: 0.4, SpeedMs: 2.6, Curvature: 1.5},
// Tabac (right).
{X: 3.3, Y: 1.75, HeadingRad: 1.0, SpeedMs: 2.3, Curvature: 1.4},
// Swimming pool (left-right-left).
{X: 2.95, Y: 1.55, HeadingRad: 1.7, SpeedMs: 2.1, Curvature: 1.8},
{X: 2.6, Y: 1.65, HeadingRad: 2.6, SpeedMs: 2.1, Curvature: 1.8},
{X: 2.4, Y: 1.9, HeadingRad: -2.7, SpeedMs: 2.3, Curvature: 1.4},
// Rascasse (left).
{X: 2.4, Y: 2.2, HeadingRad: -3.0, SpeedMs: 2.2, Curvature: 1.6},
// Anthony Noghes (left onto pit straight).
{X: 2.6, Y: 2.4, HeadingRad: 2.8, SpeedMs: 2.5, Curvature: 1.0},
// Pit straight (back to start).
{X: 3.2, Y: 2.4, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0},
{X: 3.7, Y: 2.4, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0},
{X: 3.95, Y: 2.3, HeadingRad: -0.3, SpeedMs: 2.8, Curvature: 0.8},
}
b := ComputeBounds(wps)
return Track{
ID: "monaco",
Name: "Monaco",
Description: "Circuit de Monaco. Narrow street circuit, tight hairpins, no room for error.",
AuthorID: "system",
Visibility: "system",
Scale: 27,
LengthM: b.LengthM,
WidthM: b.WidthM,
LaneWidthM: 0.18,
Surface: "tile",
Tags: []string{"f1", "street", "tight", "slow", "monaco"},
Centerline: wps,
Bounds: b,
BestLapHolder: "Verstappen",
}
}
// BarcelonaCatalunya — mix of high-speed and technical, used for testing.
// Length ≈ 4.675 km IRL → 4.3 × 2.9 m.
func BarcelonaCatalunya() Track {
const (
lengthM = 4.3
widthM = 2.9
)
wps := []Waypoint{
// Main straight.
{X: 0.3, Y: 0.4, HeadingRad: 0, SpeedMs: 4.5, Curvature: 0},
{X: 1.4, Y: 0.4, HeadingRad: 0, SpeedMs: 4.5, Curvature: 0},
// Turn 1 — Elf (right hairpin).
{X: 1.7, Y: 0.65, HeadingRad: math.Pi / 2, SpeedMs: 2.2, Curvature: 1.6},
{X: 1.5, Y: 0.95, HeadingRad: math.Pi, SpeedMs: 2.0, Curvature: 2.0},
// Turn 2-3 — right kink.
{X: 1.2, Y: 1.1, HeadingRad: math.Pi + 0.3, SpeedMs: 2.6, Curvature: 1.0},
{X: 0.95, Y: 1.0, HeadingRad: math.Pi + 0.9, SpeedMs: 2.6, Curvature: 1.0},
// Turn 4 — Repsol (left).
{X: 0.8, Y: 1.2, HeadingRad: -math.Pi / 2, SpeedMs: 2.4, Curvature: 1.2},
// Turn 5 — fast right.
{X: 1.05, Y: 1.5, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0.7},
// Turn 7-8 — slow left-right.
{X: 1.3, Y: 1.65, HeadingRad: math.Pi / 2, SpeedMs: 2.4, Curvature: 1.0},
{X: 1.15, Y: 1.85, HeadingRad: math.Pi, SpeedMs: 2.4, Curvature: 1.0},
// Turn 9-10 — Campsa (fast right).
{X: 1.4, Y: 2.0, HeadingRad: math.Pi - 0.5, SpeedMs: 3.0, Curvature: 0.7},
{X: 1.7, Y: 2.0, HeadingRad: math.Pi - 1.0, SpeedMs: 3.0, Curvature: 0.7},
// Turn 12-13 — long right.
{X: 1.95, Y: 2.2, HeadingRad: math.Pi / 2, SpeedMs: 2.8, Curvature: 0.5},
{X: 1.65, Y: 2.5, HeadingRad: 0.3, SpeedMs: 2.8, Curvature: 0.5},
// Turn 14-15 — fast right curve.
{X: 2.2, Y: 2.7, HeadingRad: 0.7, SpeedMs: 3.2, Curvature: 0.5},
{X: 2.8, Y: 2.7, HeadingRad: 1.1, SpeedMs: 3.4, Curvature: 0.5},
// Turn 16 — La Caixa (right hairpin).
{X: 3.1, Y: 2.5, HeadingRad: math.Pi / 2 + 0.4, SpeedMs: 2.2, Curvature: 1.4},
{X: 2.95, Y: 2.2, HeadingRad: math.Pi + 0.4, SpeedMs: 2.2, Curvature: 1.4},
// Back straight.
{X: 3.3, Y: 1.9, HeadingRad: 0, SpeedMs: 4.0, Curvature: 0},
{X: 4.0, Y: 1.9, HeadingRad: 0, SpeedMs: 4.0, Curvature: 0},
// Turn 16bis-17 (chicane before start).
{X: 4.05, Y: 1.6, HeadingRad: -math.Pi / 2, SpeedMs: 2.4, Curvature: 1.4},
{X: 3.8, Y: 1.4, HeadingRad: math.Pi, SpeedMs: 2.4, Curvature: 1.4},
{X: 3.5, Y: 1.5, HeadingRad: math.Pi + math.Pi/4, SpeedMs: 2.6, Curvature: 1.0},
// Approach to start.
{X: 3.1, Y: 1.25, HeadingRad: -math.Pi / 2 - 0.4, SpeedMs: 3.0, Curvature: 0.7},
{X: 2.7, Y: 1.0, HeadingRad: -math.Pi / 2 - 0.9, SpeedMs: 3.0, Curvature: 0.7},
{X: 2.3, Y: 0.7, HeadingRad: math.Pi + 0.4, SpeedMs: 3.4, Curvature: 0.5},
{X: 1.6, Y: 0.5, HeadingRad: math.Pi + 0.2, SpeedMs: 3.6, Curvature: 0.4},
{X: 0.9, Y: 0.42, HeadingRad: 0, SpeedMs: 4.0, Curvature: 0},
}
b := ComputeBounds(wps)
return Track{
ID: "barcelona-catalunya",
Name: "Barcelona-Catalunya",
Description: "Circuit de Barcelona-Catalunya. Mix of high-speed corners and technical chicanes.",
AuthorID: "system",
Visibility: "system",
Scale: 27,
LengthM: b.LengthM,
WidthM: b.WidthM,
LaneWidthM: 0.18,
Surface: "tile",
Tags: []string{"f1", "balanced", "testing", "barcelona"},
Centerline: wps,
Bounds: b,
BestLapHolder: "Verstappen",
}
}
// AustriaRedBullRing — short, fast, lots of elevation IRL.
// Length ≈ 4.318 km IRL → 4.0 × 2.4 m.
func AustriaRedBullRing() Track {
const (
lengthM = 4.0
widthM = 2.4
)
wps := []Waypoint{
// Main straight.
{X: 0.3, Y: 0.4, HeadingRad: 0, SpeedMs: 4.8, Curvature: 0},
{X: 1.6, Y: 0.4, HeadingRad: 0, SpeedMs: 4.8, Curvature: 0},
// Turn 1 — right hairpin.
{X: 1.85, Y: 0.65, HeadingRad: math.Pi / 2, SpeedMs: 2.0, Curvature: 1.8},
{X: 1.6, Y: 0.95, HeadingRad: math.Pi, SpeedMs: 2.0, Curvature: 1.8},
// Turn 2 — fast right.
{X: 1.3, Y: 1.0, HeadingRad: math.Pi + 0.5, SpeedMs: 3.0, Curvature: 0.7},
// Turn 3 — left.
{X: 1.05, Y: 1.2, HeadingRad: -math.Pi / 2, SpeedMs: 2.6, Curvature: 1.0},
// Turn 4 — Remus (fast uphill right).
{X: 1.4, Y: 1.4, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0.5},
{X: 1.85, Y: 1.4, HeadingRad: math.Pi / 2 + 0.2, SpeedMs: 3.2, Curvature: 0.5},
// Turn 5-6 — fast right-left.
{X: 2.1, Y: 1.65, HeadingRad: math.Pi, SpeedMs: 3.0, Curvature: 0.7},
{X: 2.0, Y: 1.85, HeadingRad: -math.Pi / 2, SpeedMs: 3.0, Curvature: 0.7},
// Turn 7 — right hairpin.
{X: 2.25, Y: 2.05, HeadingRad: 0, SpeedMs: 2.0, Curvature: 1.8},
{X: 2.4, Y: 2.0, HeadingRad: math.Pi / 2, SpeedMs: 2.0, Curvature: 1.8},
// Turn 8 — left hairpin.
{X: 2.4, Y: 2.2, HeadingRad: math.Pi, SpeedMs: 2.0, Curvature: 1.8},
{X: 2.2, Y: 2.3, HeadingRad: -math.Pi / 2, SpeedMs: 2.0, Curvature: 1.8},
// Turn 9 — back straight start (left kink).
{X: 2.55, Y: 2.4, HeadingRad: -0.2, SpeedMs: 3.4, Curvature: 0.4},
{X: 3.0, Y: 2.4, HeadingRad: 0, SpeedMs: 4.0, Curvature: 0},
{X: 3.5, Y: 2.4, HeadingRad: 0, SpeedMs: 4.5, Curvature: 0},
// Turn 10 — fast right.
{X: 3.85, Y: 2.15, HeadingRad: math.Pi / 2 + 0.2, SpeedMs: 3.4, Curvature: 0.6},
// Back to pit straight via Schumacher esses.
{X: 3.6, Y: 1.8, HeadingRad: math.Pi, SpeedMs: 3.0, Curvature: 0.8},
{X: 3.25, Y: 1.85, HeadingRad: math.Pi + 0.6, SpeedMs: 3.0, Curvature: 0.8},
{X: 3.0, Y: 1.6, HeadingRad: -math.Pi / 2 - 0.4, SpeedMs: 3.2, Curvature: 0.6},
{X: 2.6, Y: 1.4, HeadingRad: -math.Pi, SpeedMs: 3.4, Curvature: 0.5},
{X: 2.0, Y: 1.2, HeadingRad: math.Pi - 0.5, SpeedMs: 3.6, Curvature: 0.4},
{X: 1.4, Y: 0.8, HeadingRad: math.Pi - 0.2, SpeedMs: 3.8, Curvature: 0.3},
{X: 0.8, Y: 0.5, HeadingRad: 0, SpeedMs: 4.2, Curvature: 0.2},
}
b := ComputeBounds(wps)
return Track{
ID: "austria-redbull-ring",
Name: "Austria (Red Bull Ring)",
Description: "Red Bull Ring. Short, fast, three straights separated by hairpins.",
AuthorID: "system",
Visibility: "system",
Scale: 27,
LengthM: b.LengthM,
WidthM: b.WidthM,
LaneWidthM: 0.18,
Surface: "wood",
Tags: []string{"f1", "short", "fast", "austria"},
Centerline: wps,
Bounds: b,
BestLapHolder: "Verstappen",
}
}
// GreatBritainSilverstone — fast, flowing, essex corners.
// Length ≈ 5.891 km IRL → 4.4 × 3.0 m.
func GreatBritainSilverstone() Track {
const (
lengthM = 4.4
widthM = 3.0
)
wps := []Waypoint{
// Main straight (Wellington / pit straight), bottom of room.
{X: 0.3, Y: 0.3, HeadingRad: 0, SpeedMs: 5.0, Curvature: 0},
{X: 2.4, Y: 0.3, HeadingRad: 0, SpeedMs: 5.0, Curvature: 0},
// Turn 1 — Abbey (right).
{X: 2.8, Y: 0.45, HeadingRad: math.Pi / 2, SpeedMs: 3.0, Curvature: 0.8},
// Turn 2 — Farm Curve (left).
{X: 2.6, Y: 0.8, HeadingRad: math.Pi, SpeedMs: 3.4, Curvature: 0.6},
// Turn 3 — Village (right kink).
{X: 2.95, Y: 1.05, HeadingRad: math.Pi / 2 + 0.5, SpeedMs: 3.2, Curvature: 0.7},
// Turn 4 — The Loop (right hairpin).
{X: 3.3, Y: 1.3, HeadingRad: 0, SpeedMs: 2.2, Curvature: 1.6},
{X: 3.6, Y: 1.15, HeadingRad: -math.Pi / 2, SpeedMs: 2.2, Curvature: 1.6},
// Turn 5 — Aintree (fast left).
{X: 3.6, Y: 0.8, HeadingRad: -math.Pi, SpeedMs: 3.0, Curvature: 0.7},
// Turn 6 — Brooklands (right).
{X: 3.85, Y: 0.55, HeadingRad: -math.Pi / 2, SpeedMs: 2.6, Curvature: 1.0},
// Turn 7 — Luffield (long right).
{X: 3.55, Y: 0.25, HeadingRad: 0, SpeedMs: 2.4, Curvature: 1.0},
{X: 3.25, Y: 0.25, HeadingRad: math.Pi / 2, SpeedMs: 2.4, Curvature: 1.0},
// Turn 8-9 — Woodcote (right kink onto pit straight).
{X: 3.05, Y: 0.55, HeadingRad: math.Pi / 2 + 0.3, SpeedMs: 3.4, Curvature: 0.5},
{X: 2.85, Y: 0.45, HeadingRad: math.Pi, SpeedMs: 4.0, Curvature: 0.3},
// (mid-pit straight into Copse corner)
{X: 1.8, Y: 0.3, HeadingRad: 0, SpeedMs: 4.6, Curvature: 0},
// Copse (fast right).
{X: 1.2, Y: 0.45, HeadingRad: -math.Pi / 2 - 0.2, SpeedMs: 3.8, Curvature: 0.4},
// Maggotts (right curve, swept).
{X: 1.0, Y: 0.85, HeadingRad: -math.Pi - 0.4, SpeedMs: 3.6, Curvature: 0.5},
// Beckets (left hairpin-ish).
{X: 0.7, Y: 1.15, HeadingRad: math.Pi / 2 + 0.3, SpeedMs: 2.4, Curvature: 1.2},
{X: 0.5, Y: 0.9, HeadingRad: 0, SpeedMs: 2.6, Curvature: 0.9},
// Chapel (right kink).
{X: 0.7, Y: 0.65, HeadingRad: -math.Pi / 2, SpeedMs: 3.0, Curvature: 0.7},
// Hangar (left).
{X: 0.45, Y: 0.45, HeadingRad: -math.Pi - 0.4, SpeedMs: 2.6, Curvature: 1.0},
// Stowe (right).
{X: 0.75, Y: 1.0, HeadingRad: math.Pi / 2 - 0.4, SpeedMs: 2.4, Curvature: 1.0},
// Vale (left kink).
{X: 0.5, Y: 1.5, HeadingRad: -math.Pi - 0.4, SpeedMs: 2.6, Curvature: 0.8},
// Club (right kink onto Wellington).
{X: 0.3, Y: 1.15, HeadingRad: -math.Pi / 2, SpeedMs: 2.4, Curvature: 1.0},
// Top return straight + kink back to Wellington.
{X: 0.7, Y: 1.6, HeadingRad: math.Pi / 4, SpeedMs: 3.4, Curvature: 0.5},
{X: 2.0, Y: 1.95, HeadingRad: math.Pi / 2 + 0.4, SpeedMs: 3.0, Curvature: 0.7},
{X: 3.0, Y: 2.4, HeadingRad: math.Pi + 0.2, SpeedMs: 3.4, Curvature: 0.4},
{X: 2.4, Y: 2.65, HeadingRad: -math.Pi / 2 - 0.3, SpeedMs: 4.0, Curvature: 0.3},
{X: 1.4, Y: 2.6, HeadingRad: -math.Pi, SpeedMs: 4.4, Curvature: 0.2},
{X: 0.5, Y: 2.3, HeadingRad: -math.Pi + 0.3, SpeedMs: 4.8, Curvature: 0.2},
{X: 0.3, Y: 1.7, HeadingRad: -math.Pi / 2 + 0.3, SpeedMs: 5.0, Curvature: 0.1},
}
b := ComputeBounds(wps)
return Track{
ID: "great-britain-silverstone",
Name: "Great Britain (Silverstone)",
Description: "Silverstone Circuit. Fast, flowing corners; home of British motorsport.",
AuthorID: "system",
Visibility: "system",
Scale: 27,
LengthM: b.LengthM,
WidthM: b.WidthM,
LaneWidthM: 0.18,
Surface: "carpet",
Tags: []string{"f1", "fast", "flowing", "silverstone"},
Centerline: wps,
Bounds: b,
BestLapHolder: "Hamilton",
}
}
// BelgiumSpa — long, fast, Eau Rouge / Raidillon, varied weather.
// Length ≈ 7.004 km IRL → 4.5 × 3.0 m.
func BelgiumSpa() Track {
const (
lengthM = 4.5
widthM = 3.0
)
wps := []Waypoint{
// Main straight (La Source exit).
{X: 0.4, Y: 0.5, HeadingRad: 0, SpeedMs: 4.8, Curvature: 0},
{X: 1.6, Y: 0.5, HeadingRad: 0, SpeedMs: 4.8, Curvature: 0},
// Eau Rouge / Raidillon (fast uphill left-right-left).
{X: 1.95, Y: 0.6, HeadingRad: 0.3, SpeedMs: 4.2, Curvature: 0.5},
{X: 2.2, Y: 0.95, HeadingRad: 0.9, SpeedMs: 4.0, Curvature: 0.6},
{X: 2.1, Y: 1.3, HeadingRad: math.Pi + 0.4, SpeedMs: 4.0, Curvature: 0.6},
// Kemmel straight.
{X: 2.7, Y: 1.5, HeadingRad: 0, SpeedMs: 5.0, Curvature: 0},
{X: 3.5, Y: 1.5, HeadingRad: 0, SpeedMs: 5.0, Curvature: 0},
// Les Combes (chicane).
{X: 3.85, Y: 1.3, HeadingRad: -math.Pi / 2, SpeedMs: 2.4, Curvature: 1.4},
{X: 3.6, Y: 1.1, HeadingRad: math.Pi + 0.3, SpeedMs: 2.6, Curvature: 1.2},
{X: 3.3, Y: 1.25, HeadingRad: math.Pi + 0.9, SpeedMs: 2.6, Curvature: 1.2},
// Malmedy (fast right sweep).
{X: 3.5, Y: 1.55, HeadingRad: math.Pi / 2, SpeedMs: 3.6, Curvature: 0.4},
// Rivage (right hairpin).
{X: 3.25, Y: 1.85, HeadingRad: 0, SpeedMs: 2.2, Curvature: 1.6},
{X: 3.05, Y: 1.7, HeadingRad: -math.Pi / 2, SpeedMs: 2.4, Curvature: 1.4},
// Campus (left kink).
{X: 3.25, Y: 1.5, HeadingRad: 0, SpeedMs: 3.0, Curvature: 0.7},
// Stavelot (fast right).
{X: 3.7, Y: 1.65, HeadingRad: math.Pi / 2 + 0.3, SpeedMs: 3.4, Curvature: 0.5},
// Up to Blanchimont (long right).
{X: 4.1, Y: 2.0, HeadingRad: math.Pi / 2 + 0.6, SpeedMs: 4.0, Curvature: 0.3},
// Blanchimont (very fast right kink).
{X: 4.25, Y: 2.4, HeadingRad: math.Pi + 0.4, SpeedMs: 4.6, Curvature: 0.3},
// Bus stop chicane (slow left-right).
{X: 4.0, Y: 2.7, HeadingRad: -math.Pi / 2 - 0.3, SpeedMs: 2.4, Curvature: 1.4},
{X: 3.7, Y: 2.55, HeadingRad: math.Pi, SpeedMs: 2.4, Curvature: 1.4},
{X: 3.55, Y: 2.75, HeadingRad: math.Pi + 0.7, SpeedMs: 2.4, Curvature: 1.4},
// Back to La Source hairpin.
{X: 3.05, Y: 2.7, HeadingRad: -math.Pi / 2, SpeedMs: 2.0, Curvature: 1.8},
{X: 2.85, Y: 2.45, HeadingRad: 0, SpeedMs: 2.0, Curvature: 1.8},
// Pit straight.
{X: 2.0, Y: 2.2, HeadingRad: math.Pi - 0.3, SpeedMs: 3.6, Curvature: 0.3},
{X: 1.2, Y: 1.6, HeadingRad: math.Pi - 0.6, SpeedMs: 4.0, Curvature: 0.2},
{X: 0.6, Y: 1.05, HeadingRad: math.Pi - 0.3, SpeedMs: 4.4, Curvature: 0.2},
}
b := ComputeBounds(wps)
return Track{
ID: "belgium-spa",
Name: "Belgium (Spa-Francorchamps)",
Description: "Spa-Francorchamps. Long, fast, legendary Eau Rouge / Raidillon.",
AuthorID: "system",
Visibility: "system",
Scale: 27,
LengthM: b.LengthM,
WidthM: b.WidthM,
LaneWidthM: 0.18,
Surface: "carpet",
Tags: []string{"f1", "long", "fast", "spa"},
Centerline: wps,
Bounds: b,
BestLapHolder: "Verstappen",
}
}
// ---------------------------------------------------------------------------
// Cars — F1 2024 constructors
//
// Real cars are 1:1 with ~5.7 × 2.0 × 0.95 m, ~798 kg, ~1000 hp.
// We model them at 1/24 scale so the largest dimension (~238 mm) still
// fits the catalog physical-envelope check.
// ---------------------------------------------------------------------------
// RedBullRB20 — Oracle Red Bull Racing, Verstappen / Perez.
// 2024: 9 wins (last year of dominant era), Constructors' champion.
func RedBullRB20() Car {
return Car{
ID: "f1-2024-redbull-rb20",
Name: "Oracle Red Bull Racing RB20",
OwnerID: "system",
Visibility: "system",
Scale: 24,
LengthMm: 238, // 5.7m IRL
WidthMm: 83, // 2.0m IRL
HeightMm: 40, // 0.95m IRL
WeightG: 33, // 798 kg IRL
Chassis: Chassis{
Model: "RB20 carbon monocoque",
Material: "carbon-fibre",
Printed: false,
WheelbaseMm: 150, // 3.6m IRL
TrackMm: 75, // 1.8m IRL
},
Motor: Motor{
Kind: "turbo-hybrid",
Class: "F1 PU",
KV: 0,
PowerW: 746000, // ~1000 hp ICE + MGU-K, peak 746 kW
},
Battery: Battery{
VoltageV: 0,
CapacityMah: 0,
Cells: 0,
Chemistry: "li-ion MGU-K",
},
Drive: "2WD",
TopSpeedMs: 95, // ~340 km/h
ColorHex: "#1e3a8a", // dark blue + red/yellow accents
Active: true,
TotalRaces: 0,
TotalLaps: 0,
BestLapMs: 0,
}
}
// FerrariSF24 — Scuderia Ferrari, Leclerc / Sainz.
func FerrariSF24() Car {
return Car{
ID: "f1-2024-ferrari-sf24",
Name: "Scuderia Ferrari SF-24",
OwnerID: "system",
Visibility: "system",
Scale: 24,
LengthMm: 238,
WidthMm: 83,
HeightMm: 40,
WeightG: 33,
Chassis: Chassis{
Model: "SF-24 carbon monocoque",
Material: "carbon-fibre",
Printed: false,
WheelbaseMm: 150,
TrackMm: 75,
},
Motor: Motor{
Kind: "turbo-hybrid",
Class: "F1 PU 066/12",
KV: 0,
PowerW: 746000,
},
Battery: Battery{
Chemistry: "li-ion MGU-K",
},
Drive: "2WD",
TopSpeedMs: 94,
ColorHex: "#dc2626", // rosso corsa
Active: true,
}
}
// McLarenMCL38 — McLaren Racing, Norris / Piastri. 2024 Constructors' runner-up.
func McLarenMCL38() Car {
return Car{
ID: "f1-2024-mclaren-mcl38",
Name: "McLaren MCL38",
OwnerID: "system",
Visibility: "system",
Scale: 24,
LengthMm: 238,
WidthMm: 83,
HeightMm: 40,
WeightG: 33,
Chassis: Chassis{
Model: "MCL38 carbon monocoque",
Material: "carbon-fibre",
Printed: false,
WheelbaseMm: 150,
TrackMm: 75,
},
Motor: Motor{
Kind: "turbo-hybrid",
Class: "F1 PU Mercedes-derived",
KV: 0,
PowerW: 746000,
},
Battery: Battery{
Chemistry: "li-ion MGU-K",
},
Drive: "2WD",
TopSpeedMs: 94,
ColorHex: "#ea580c", // papaya orange
Active: true,
}
}
// MercedesW15 — Mercedes-AMG Petronas, Russell / Hamilton (last year of Hamilton at Mercedes).
func MercedesW15() Car {
return Car{
ID: "f1-2024-mercedes-w15",
Name: "Mercedes-AMG W15",
OwnerID: "system",
Visibility: "system",
Scale: 24,
LengthMm: 238,
WidthMm: 83,
HeightMm: 40,
WeightG: 33,
Chassis: Chassis{
Model: "W15 carbon monocoque",
Material: "carbon-fibre",
Printed: false,
WheelbaseMm: 150,
TrackMm: 75,
},
Motor: Motor{
Kind: "turbo-hybrid",
Class: "F1 PU M15",
KV: 0,
PowerW: 746000,
},
Battery: Battery{
Chemistry: "li-ion MGU-K",
},
Drive: "2WD",
TopSpeedMs: 94,
ColorHex: "#06b6d4", // silver-petronas cyan
Active: true,
}
}
// AstonMartinAMR24 — Aston Martin Aramco, Alonso / Stroll.
func AstonMartinAMR24() Car {
return Car{
ID: "f1-2024-astonmartin-amr24",
Name: "Aston Martin Aramco AMR24",
OwnerID: "system",
Visibility: "system",
Scale: 24,
LengthMm: 238,
WidthMm: 83,
HeightMm: 40,
WeightG: 33,
Chassis: Chassis{
Model: "AMR24 carbon monocoque",
Material: "carbon-fibre",
Printed: false,
WheelbaseMm: 150,
TrackMm: 75,
},
Motor: Motor{
Kind: "turbo-hybrid",
Class: "F1 PU Mercedes-derived",
KV: 0,
PowerW: 746000,
},
Battery: Battery{
Chemistry: "li-ion MGU-K",
},
Drive: "2WD",
TopSpeedMs: 94,
ColorHex: "#16a34a", // racing green
Active: true,
}
}
// ComputeBounds derives a bounding box from a closed loop of waypoints.
func ComputeBounds(loop []Waypoint) Bounds {
if len(loop) == 0 {
return Bounds{}
}
b := Bounds{
MinX: loop[0].X,
MinY: loop[0].Y,
MaxX: loop[0].X,
MaxY: loop[0].Y,
}
for _, p := range loop[1:] {
if p.X < b.MinX {
b.MinX = p.X
}
if p.Y < b.MinY {
b.MinY = p.Y
}
if p.X > b.MaxX {
b.MaxX = p.X
}
if p.Y > b.MaxY {
b.MaxY = p.Y
}
}
b.LengthM = b.MaxX - b.MinX
b.WidthM = b.MaxY - b.MinY
return b
}