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
}
@@ -0,0 +1,40 @@
package seeddefs
import "testing"
func TestTracksFitInRoom(t *testing.T) {
const (
roomLen = 4.5
roomWid = 3.2
)
for _, tr := range DefaultTracks() {
if tr.Bounds.LengthM > roomLen {
t.Errorf("track %s: length %.3f m exceeds 4.5 m", tr.ID, tr.Bounds.LengthM)
}
if tr.Bounds.WidthM > roomWid {
t.Errorf("track %s: width %.3f m exceeds 3.2 m", tr.ID, tr.Bounds.WidthM)
}
if len(tr.Centerline) < 8 {
t.Errorf("track %s: too few waypoints (%d)", tr.ID, len(tr.Centerline))
}
t.Logf("%-30s %5.2fm x %5.2fm %2d waypoints", tr.ID,
tr.Bounds.LengthM, tr.Bounds.WidthM, len(tr.Centerline))
}
}
func TestCarsValid(t *testing.T) {
for _, c := range DefaultCars() {
if c.LengthMm < 200 || c.LengthMm > 260 {
t.Errorf("car %s: length %.0f mm out of F1 1/24 range (200..260)", c.ID, c.LengthMm)
}
if c.WidthMm < 70 || c.WidthMm > 100 {
t.Errorf("car %s: width %.0f mm out of F1 1/24 range", c.ID, c.WidthMm)
}
if c.Motor.PowerW < 500000 {
t.Errorf("car %s: power %.0f W too low for F1 PU", c.ID, c.Motor.PowerW)
}
t.Logf("%-35s %dx%dx%d mm, %d W, %d m/s", c.ID,
int(c.LengthMm), int(c.WidthMm), int(c.HeightMm),
int(c.Motor.PowerW), int(c.TopSpeedMs))
}
}
+84
View File
@@ -0,0 +1,84 @@
package seeddefs
// Waypoint mirrors catalog.Waypoint — duplicated here to keep the
// seeddefs package free of catalog (and thus postgres) imports.
type Waypoint struct {
X float64
Y float64
HeadingRad float64
SpeedMs float64
Curvature float64
}
type Bounds struct {
MinX, MinY, MaxX, MaxY float64
LengthM float64
WidthM float64
}
type Track struct {
ID string
Name string
Description string
AuthorID string
Visibility string
Scale int
LengthM float64
WidthM float64
LaneWidthM float64
Surface string
Tags []string
Centerline []Waypoint
Bounds Bounds
BestLapMs int64
BestLapHolder string
}
type Chassis struct {
Model string
Material string
Printed bool
WheelbaseMm float64
TrackMm float64
}
type Motor struct {
Kind string
Class string
KV int
PowerW float64
}
type Battery struct {
VoltageV float64
CapacityMah int
Cells int
Chemistry string
}
type Car struct {
ID string
Name string
OwnerID string
Visibility string
Scale int
LengthMm float64
WidthMm float64
HeightMm float64
WeightG float64
Chassis Chassis
Motor Motor
Battery Battery
Drive string
TopSpeedMs float64
ColorHex string
Active bool
TotalDistanceM float64
TotalRaces int
TotalLaps int
BestLapMs int64
}