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
+609
View File
@@ -0,0 +1,609 @@
package catalog
import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
)
// Errors returned by the catalog service.
var (
ErrNotFound = errors.New("not found")
ErrAlreadyExists = errors.New("already exists")
ErrInvalidInput = errors.New("invalid input")
ErrImmutableSystem = errors.New("system resources are immutable")
ErrStoreMissing = errors.New("catalog: store not configured")
)
// Validation bounds for the user's setup:
// - largest room 4.46 m × 3.19 m → tracks ≤ 4.5 m × 3.2 m bounding box
// - car scale 1/27 → car length 50..200 mm (typical 1/27 touring envelope)
// - 3D-printer Bambu A1 build volume → 220×220×250 mm
const (
maxTrackLengthM = 4.5
maxTrackWidthM = 3.2
maxLaneWidthM = 1.0
minCarLengthMm = 40.0
maxCarLengthMm = 260.0 // 1/24 F1 ≈ 238 мм
minCarWidthMm = 20.0
maxCarWidthMm = 120.0
)
// CreateTrackOptions are the inputs for CreateTrack.
type CreateTrackOptions struct {
ID string // optional; auto-generated if empty
Name string // required
Description string // optional
AuthorID string // optional; defaults to "system"
Visibility Visibility // optional; defaults to VisibilityPublic
Scale int // optional; defaults to 27
LengthM float64 // optional; computed from centerline if 0
WidthM float64 // optional; computed from centerline if 0
LaneWidthM float64 // optional; defaults to 0.20
Surface Surface // optional; defaults to carpet
Tags []string // optional
Centerline []Waypoint // optional; defaults to a parametric oval
}
// Validate returns an error if opts is incomplete.
func (o CreateTrackOptions) Validate() error {
if strings.TrimSpace(o.Name) == "" {
return fmt.Errorf("%w: name required", ErrInvalidInput)
}
if o.LengthM < 0 || o.LengthM > maxTrackLengthM {
return fmt.Errorf("%w: length_m must be in [0, %.1f]", ErrInvalidInput, maxTrackLengthM)
}
if o.WidthM < 0 || o.WidthM > maxTrackWidthM {
return fmt.Errorf("%w: width_m must be in [0, %.1f]", ErrInvalidInput, maxTrackWidthM)
}
if o.LaneWidthM < 0 || o.LaneWidthM > maxLaneWidthM {
return fmt.Errorf("%w: lane_width_m must be in [0, %.1f]", ErrInvalidInput, maxLaneWidthM)
}
if o.Visibility == VisibilitySystem {
return fmt.Errorf("%w: cannot create system track via API", ErrInvalidInput)
}
return nil
}
// CreateTrack inserts a new user-authored track.
func (s *Service) CreateTrack(ctx context.Context, opts CreateTrackOptions) (TrackMeta, error) {
if err := opts.Validate(); err != nil {
return TrackMeta{}, err
}
id := opts.ID
if id == "" {
id = slugify(opts.Name)
}
vis := opts.Visibility
if vis == "" {
vis = VisibilityPublic
}
auth := opts.AuthorID
if auth == "" {
auth = AuthorSystem
}
scale := opts.Scale
if scale == 0 {
scale = 27
}
sfc := opts.Surface
if sfc == "" {
sfc = SurfaceCarpet
}
if _, exists := s.getTrackLocked(id); exists {
return TrackMeta{}, fmt.Errorf("%w: track %q", ErrAlreadyExists, id)
}
now := time.Now().UnixMilli()
t := TrackMeta{
ID: id,
Name: strings.TrimSpace(opts.Name),
Description: opts.Description,
AuthorID: auth,
Visibility: vis,
Scale: scale,
LengthM: opts.LengthM,
WidthM: opts.WidthM,
LaneWidthM: opts.LaneWidthM,
Surface: sfc,
Tags: append([]string(nil), opts.Tags...),
Centerline: append([]Waypoint(nil), opts.Centerline...),
CreatedMs: now,
UpdatedMs: now,
}
s.normalizeTrack(&t)
if t.Bounds.MaxX > maxTrackLengthM || t.Bounds.MaxY > maxTrackWidthM {
return TrackMeta{}, fmt.Errorf("%w: track too large for 446x319 cm room (got %.2fx%.2f m)",
ErrInvalidInput, t.Bounds.MaxX, t.Bounds.MaxY)
}
if err := s.persistUpsertTrack(ctx, t); err != nil {
return TrackMeta{}, err
}
return t, nil
}
// UpdateTrackOptions carries the patch fields for UpdateTrack.
type UpdateTrackOptions struct {
Name *string
Description *string
Visibility *Visibility
LengthM *float64
WidthM *float64
LaneWidthM *float64
Surface *Surface
Tags *[]string
Centerline *[]Waypoint
}
// UpdateTrack applies a partial update to an existing track.
func (s *Service) UpdateTrack(ctx context.Context, id string, opts UpdateTrackOptions) (TrackMeta, error) {
s.mu.Lock()
t, ok := s.tracks[id]
if !ok {
s.mu.Unlock()
return TrackMeta{}, fmt.Errorf("%w: track %q", ErrNotFound, id)
}
if t.Visibility == VisibilitySystem {
s.mu.Unlock()
return TrackMeta{}, fmt.Errorf("%w: track %q", ErrImmutableSystem, id)
}
if opts.Name != nil {
t.Name = strings.TrimSpace(*opts.Name)
}
if opts.Description != nil {
t.Description = *opts.Description
}
if opts.Visibility != nil {
if *opts.Visibility == VisibilitySystem {
s.mu.Unlock()
return TrackMeta{}, fmt.Errorf("%w: cannot promote to system", ErrInvalidInput)
}
t.Visibility = *opts.Visibility
}
if opts.LengthM != nil {
t.LengthM = *opts.LengthM
}
if opts.WidthM != nil {
t.WidthM = *opts.WidthM
}
if opts.LaneWidthM != nil {
t.LaneWidthM = *opts.LaneWidthM
}
if opts.Surface != nil {
t.Surface = *opts.Surface
}
if opts.Tags != nil {
t.Tags = append([]string(nil), *opts.Tags...)
}
if opts.Centerline != nil {
t.Centerline = append([]Waypoint(nil), *opts.Centerline...)
t.Bounds = computeBounds(t.Centerline)
t.LengthM = t.Bounds.LengthM
t.WidthM = t.Bounds.WidthM
} else {
t.Bounds = computeBounds(t.Centerline)
}
t.UpdatedMs = time.Now().UnixMilli()
if t.Bounds.MaxX > maxTrackLengthM || t.Bounds.MaxY > maxTrackWidthM {
s.mu.Unlock()
return TrackMeta{}, fmt.Errorf("%w: track too large (got %.2fx%.2f m)",
ErrInvalidInput, t.Bounds.MaxX, t.Bounds.MaxY)
}
updated := *t
s.mu.Unlock()
if err := s.persistUpsertTrack(ctx, updated); err != nil {
return TrackMeta{}, err
}
return updated, nil
}
// DeleteTrack removes a track by ID. System tracks are immutable.
func (s *Service) DeleteTrack(ctx context.Context, id string) error {
s.mu.RLock()
t, ok := s.tracks[id]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("%w: track %q", ErrNotFound, id)
}
if t.Visibility == VisibilitySystem {
return fmt.Errorf("%w: track %q", ErrImmutableSystem, id)
}
return s.persistDeleteTrack(ctx, id)
}
// -------------------------------------------------------------------------
// Cars
// -------------------------------------------------------------------------
// CreateCarOptions are the inputs for CreateCar.
type CreateCarOptions struct {
ID string
Name string
OwnerID string
Visibility Visibility
Scale int
LengthMm float64
WidthMm float64
HeightMm float64
WeightG float64
Chassis ChassisSpec
Motor MotorSpec
Battery BatterySpec
Drive Drivetrain
TopSpeedMs float64
ColorHex string
AvatarURL string
Active *bool // optional; defaults to true
}
// Validate sanity-checks dimensions and required fields.
func (o CreateCarOptions) Validate() error {
if strings.TrimSpace(o.Name) == "" {
return fmt.Errorf("%w: name required", ErrInvalidInput)
}
if o.LengthMm < minCarLengthMm || o.LengthMm > maxCarLengthMm {
return fmt.Errorf("%w: length_mm must be in [%.0f, %.0f]", ErrInvalidInput, minCarLengthMm, maxCarLengthMm)
}
if o.WidthMm < minCarWidthMm || o.WidthMm > maxCarWidthMm {
return fmt.Errorf("%w: width_mm must be in [%.0f, %.0f]", ErrInvalidInput, minCarWidthMm, maxCarWidthMm)
}
if o.Visibility == VisibilitySystem {
return fmt.Errorf("%w: cannot create system car via API", ErrInvalidInput)
}
return nil
}
// CreateCar inserts a new car.
func (s *Service) CreateCar(ctx context.Context, opts CreateCarOptions) (CarMeta, error) {
if err := opts.Validate(); err != nil {
return CarMeta{}, err
}
id := opts.ID
if id == "" {
id = slugify(opts.Name)
}
vis := opts.Visibility
if vis == "" {
vis = VisibilityPublic
}
owner := opts.OwnerID
if owner == "" {
owner = AuthorSystem
}
scale := opts.Scale
if scale == 0 {
scale = 27
}
drive := opts.Drive
if drive == "" {
drive = Drivetrain2WD
}
active := true
if opts.Active != nil {
active = *opts.Active
}
if _, exists := s.getCarLocked(id); exists {
return CarMeta{}, fmt.Errorf("%w: car %q", ErrAlreadyExists, id)
}
now := time.Now().UnixMilli()
c := CarMeta{
ID: id,
Name: strings.TrimSpace(opts.Name),
OwnerID: owner,
Visibility: vis,
Scale: scale,
LengthMm: opts.LengthMm,
WidthMm: opts.WidthMm,
HeightMm: opts.HeightMm,
WeightG: opts.WeightG,
Chassis: opts.Chassis,
Motor: opts.Motor,
Battery: opts.Battery,
Drive: drive,
TopSpeedMs: opts.TopSpeedMs,
ColorHex: opts.ColorHex,
AvatarURL: opts.AvatarURL,
Active: active,
CreatedMs: now,
UpdatedMs: now,
}
s.normalizeCar(&c)
if err := s.persistUpsertCar(ctx, c); err != nil {
return CarMeta{}, err
}
return c, nil
}
// UpdateCarOptions is the patch for UpdateCar.
type UpdateCarOptions struct {
Name *string
Visibility *Visibility
LengthMm *float64
WidthMm *float64
HeightMm *float64
WeightG *float64
TopSpeedMs *float64
ColorHex *string
Active *bool
Chassis *ChassisSpec
Motor *MotorSpec
Battery *BatterySpec
Drive *Drivetrain
}
// UpdateCar applies a partial update.
func (s *Service) UpdateCar(ctx context.Context, id string, opts UpdateCarOptions) (CarMeta, error) {
s.mu.Lock()
c, ok := s.cars[id]
if !ok {
s.mu.Unlock()
return CarMeta{}, fmt.Errorf("%w: car %q", ErrNotFound, id)
}
if c.Visibility == VisibilitySystem {
s.mu.Unlock()
return CarMeta{}, fmt.Errorf("%w: car %q", ErrImmutableSystem, id)
}
if opts.Name != nil {
c.Name = strings.TrimSpace(*opts.Name)
}
if opts.Visibility != nil {
if *opts.Visibility == VisibilitySystem {
s.mu.Unlock()
return CarMeta{}, fmt.Errorf("%w: cannot promote to system", ErrInvalidInput)
}
c.Visibility = *opts.Visibility
}
if opts.LengthMm != nil {
c.LengthMm = *opts.LengthMm
}
if opts.WidthMm != nil {
c.WidthMm = *opts.WidthMm
}
if opts.HeightMm != nil {
c.HeightMm = *opts.HeightMm
}
if opts.WeightG != nil {
c.WeightG = *opts.WeightG
}
if opts.TopSpeedMs != nil {
c.TopSpeedMs = *opts.TopSpeedMs
}
if opts.ColorHex != nil {
c.ColorHex = *opts.ColorHex
}
if opts.Active != nil {
c.Active = *opts.Active
}
if opts.Chassis != nil {
c.Chassis = *opts.Chassis
}
if opts.Motor != nil {
c.Motor = *opts.Motor
}
if opts.Battery != nil {
c.Battery = *opts.Battery
}
if opts.Drive != nil {
c.Drive = *opts.Drive
}
c.UpdatedMs = time.Now().UnixMilli()
if c.LengthMm < minCarLengthMm || c.LengthMm > maxCarLengthMm ||
c.WidthMm < minCarWidthMm || c.WidthMm > maxCarWidthMm {
s.mu.Unlock()
return CarMeta{}, fmt.Errorf("%w: dimensions out of range", ErrInvalidInput)
}
updated := *c
s.mu.Unlock()
if err := s.persistUpsertCar(ctx, updated); err != nil {
return CarMeta{}, err
}
return updated, nil
}
// DeleteCar removes a car by id.
func (s *Service) DeleteCar(ctx context.Context, id string) error {
s.mu.RLock()
c, ok := s.cars[id]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("%w: car %q", ErrNotFound, id)
}
if c.Visibility == VisibilitySystem {
return fmt.Errorf("%w: car %q", ErrImmutableSystem, id)
}
return s.persistDeleteCar(ctx, id)
}
// SetCarStats mutates the read-mostly counters on a car (called by the
// race engine via catalog hooks). Only updates non-zero inputs.
func (s *Service) SetCarStats(ctx context.Context, id string, fn func(*CarMeta)) error {
s.mu.Lock()
c, ok := s.cars[id]
if !ok {
s.mu.Unlock()
return fmt.Errorf("%w: car %q", ErrNotFound, id)
}
fn(c)
c.UpdatedMs = time.Now().UnixMilli()
updated := *c
s.mu.Unlock()
if s.store != nil {
if err := s.store.UpdateCarStats(ctx, id, func(c *CarMeta) {
fn(c)
c.UpdatedMs = updated.UpdatedMs
}); err != nil {
return err
}
}
s.bump(Event{Kind: EventCarUpsert, CarID: id})
return nil
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
// getTrackLocked returns a shallow copy of a track from the cache.
// MUST be called with s.mu held.
func (s *Service) getTrackLocked(id string) (TrackMeta, bool) {
t, ok := s.tracks[id]
if !ok {
return TrackMeta{}, false
}
return *t, true
}
func (s *Service) getCarLocked(id string) (CarMeta, bool) {
c, ok := s.cars[id]
if !ok {
return CarMeta{}, false
}
return *c, true
}
// slugify turns "Sunset Sprint" into "sunset-sprint".
func slugify(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
prevDash := false
for _, r := range s {
switch {
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
b.WriteRune(r)
prevDash = false
default:
if !prevDash && b.Len() > 0 {
b.WriteRune('-')
prevDash = true
}
}
}
out := strings.TrimRight(b.String(), "-")
if out == "" {
out = fmt.Sprintf("item-%d", time.Now().UnixNano()%1_000_000)
}
return out
}
// computeBounds derives a Bounds from a closed loop of waypoints.
func computeBounds(loop []Waypoint) Bounds {
if len(loop) == 0 {
return Bounds{}
}
b := Bounds{
MinX: loop[0].X,
MinY: loop[0].Y,
MaxX: loop[0].X,
MaxY: loop[0].Y,
}
for _, p := range loop[1:] {
if p.X < b.MinX {
b.MinX = p.X
}
if p.Y < b.MinY {
b.MinY = p.Y
}
if p.X > b.MaxX {
b.MaxX = p.X
}
if p.Y > b.MaxY {
b.MaxY = p.Y
}
}
b.LengthM = b.MaxX - b.MinX
b.WidthM = b.MaxY - b.MinY
return b
}
// defaultCenterline generates a parametric oval centered in a box of
// size length_m × width_m. Used when the caller doesn't supply waypoints.
func defaultCenterline(lengthM, widthM float64) []Waypoint {
if lengthM <= 0 {
lengthM = 2.0
}
if widthM <= 0 {
widthM = 1.2
}
const samples = 24
cx, cy := lengthM/2, widthM/2
rx, ry := lengthM*0.45, widthM*0.45
pts := make([]Waypoint, samples)
for i := 0; i < samples; i++ {
t := float64(i) / float64(samples) * 2 * math.Pi
x := cx + rx*math.Cos(t)
y := cy + ry*math.Sin(t)
// Tangent: perpendicular to the radius.
heading := t + math.Pi/2
// Curvature: 1/r at this point (approximation using ellipse radii).
r := math.Sqrt(rx*rx*math.Sin(t)*math.Sin(t) + ry*ry*math.Cos(t)*math.Cos(t))
curv := 0.0
if r > 0 {
curv = math.Min(rx, ry) / (r * r)
}
// Speed: slower on tight curves.
speed := 4.0 - 2.0*math.Min(curv*5, 1)
pts[i] = Waypoint{X: x, Y: y, HeadingRad: heading, SpeedMs: speed, Curvature: curv}
}
return pts
}
// -------------------------------------------------------------------------
// Read APIs (Get / List / Snapshot / Stats)
// -------------------------------------------------------------------------
// ListTracksByVisibility returns tracks matching the given visibility.
func (s *Service) ListTracksByVisibility(v Visibility) []TrackMeta {
all := s.ListTracks()
out := all[:0:0]
for _, t := range all {
if t.Visibility == v {
out = append(out, t)
}
}
return out
}
// ListCarsByVisibility returns cars matching the given visibility.
func (s *Service) ListCarsByVisibility(v Visibility) []CarMeta {
all := s.ListCars()
out := all[:0:0]
for _, c := range all {
if c.Visibility == v {
out = append(out, c)
}
}
return out
}
// ListCarsByOwner returns cars owned by the given driver ID.
func (s *Service) ListCarsByOwner(ownerID string) []CarMeta {
all := s.ListCars()
out := all[:0:0]
for _, c := range all {
if c.OwnerID == ownerID {
out = append(out, c)
}
}
return out
}
+401
View File
@@ -0,0 +1,401 @@
package catalog
import (
"context"
"sync"
"testing"
"time"
"github.com/x0gp/server/internal/catalog/seeddefs"
)
// withFrozenClock is retained for compatibility with older tests that
// depended on deterministic timestamps. The Service no longer reads
// timeNowMillis directly; timestamps come from time.Now() inside
// normalizeTrack / normalizeCar, but those are only filled when
// CreatedMs / UpdatedMs are zero.
func withFrozenClock(t *testing.T) {
t.Helper()
// Intentionally a no-op; tests below no longer need clock control
// because the Service relies on time.Now() and we just check ranges.
}
// loadSeeds is a helper that converts seeddefs into catalog types and
// loads them into a memoryStore for use in tests.
func loadSeeds() (*Service, error) {
store := newMemoryStore()
ctx := context.Background()
for _, t := range seeddefs.DefaultTracks() {
cm, err := seedTrackToCatalog(t)
if err != nil {
return nil, err
}
if err := store.UpsertTrack(ctx, cm); err != nil {
return nil, err
}
}
for _, c := range seeddefs.DefaultCars() {
cm := seedCarToCatalog(c)
if err := store.UpsertCar(ctx, cm); err != nil {
return nil, err
}
}
return NewService(ctx, store)
}
func seedTrackToCatalog(t seeddefs.Track) (TrackMeta, error) {
now := time.Now().UnixMilli()
return TrackMeta{
ID: t.ID,
Name: t.Name,
Description: t.Description,
AuthorID: t.AuthorID,
Visibility: Visibility(t.Visibility),
Scale: t.Scale,
LengthM: t.LengthM,
WidthM: t.WidthM,
LaneWidthM: t.LaneWidthM,
Bounds: Bounds{
MinX: t.Bounds.MinX, MinY: t.Bounds.MinY,
MaxX: t.Bounds.MaxX, MaxY: t.Bounds.MaxY,
LengthM: t.Bounds.LengthM, WidthM: t.Bounds.WidthM,
},
Surface: Surface(t.Surface),
Tags: append([]string(nil), t.Tags...),
Centerline: waypointsFromSeed(t.Centerline),
BestLapMs: t.BestLapMs,
CreatedMs: now,
UpdatedMs: now,
}, nil
}
func seedCarToCatalog(c seeddefs.Car) CarMeta {
now := time.Now().UnixMilli()
return CarMeta{
ID: c.ID,
Name: c.Name,
OwnerID: c.OwnerID,
Visibility: Visibility(c.Visibility),
Scale: c.Scale,
LengthMm: c.LengthMm,
WidthMm: c.WidthMm,
HeightMm: c.HeightMm,
WeightG: c.WeightG,
Chassis: ChassisSpec{
Model: c.Chassis.Model, Material: c.Chassis.Material,
Printed: c.Chassis.Printed, WheelbaseMm: c.Chassis.WheelbaseMm,
TrackMm: c.Chassis.TrackMm,
},
Motor: MotorSpec{
Kind: c.Motor.Kind, Class: c.Motor.Class,
KV: c.Motor.KV, PowerW: c.Motor.PowerW,
},
Battery: BatterySpec{
VoltageV: c.Battery.VoltageV, CapacityMah: c.Battery.CapacityMah,
Cells: c.Battery.Cells, Chemistry: c.Battery.Chemistry,
},
Drive: Drivetrain(c.Drive),
TopSpeedMs: c.TopSpeedMs,
ColorHex: c.ColorHex,
Active: c.Active,
TotalRaces: c.TotalRaces,
TotalLaps: c.TotalLaps,
BestLapMs: c.BestLapMs,
CreatedMs: now,
UpdatedMs: now,
}
}
func waypointsFromSeed(in []seeddefs.Waypoint) []Waypoint {
if in == nil {
return nil
}
out := make([]Waypoint, len(in))
for i, w := range in {
out[i] = Waypoint{
X: w.X, Y: w.Y, HeadingRad: w.HeadingRad,
SpeedMs: w.SpeedMs, Curvature: w.Curvature,
}
}
return out
}
func TestSeedsFitInRoom(t *testing.T) {
tracks := seeddefs.DefaultTracks()
cars := seeddefs.DefaultCars()
if len(tracks) == 0 {
t.Fatal("no default tracks")
}
if len(cars) == 0 {
t.Fatal("no default cars")
}
const (
roomLen = 4.46
roomWid = 3.19
)
for _, tr := range tracks {
if tr.Bounds.LengthM > roomLen {
t.Errorf("track %s: length %.2f m exceeds room 4.46 m", tr.ID, tr.Bounds.LengthM)
}
if tr.Bounds.WidthM > roomWid {
t.Errorf("track %s: width %.2f m exceeds room 3.19 m", tr.ID, tr.Bounds.WidthM)
}
if len(tr.Centerline) < 8 {
t.Errorf("track %s: centerline too short (%d)", tr.ID, len(tr.Centerline))
}
}
for _, c := range cars {
if c.Scale != 24 {
t.Errorf("car %s: scale=%d, expected 24 (F1 1/24)", c.ID, c.Scale)
}
if c.Visibility != "system" {
t.Errorf("car %s: not system visibility", c.ID)
}
if c.LengthMm < 200 || c.LengthMm > 260 {
t.Errorf("car %s: length %.0f mm out of F1 1/24 range", c.ID, c.LengthMm)
}
}
}
func TestCreateAndGetTrack(t *testing.T) {
s, err := loadSeeds()
if err != nil {
t.Fatalf("loadSeeds: %v", err)
}
defer s.Stop()
got, ok := s.GetTrack("monaco")
if !ok {
t.Fatal("expected to find monaco")
}
if got.ID != "monaco" {
t.Errorf("got id %q", got.ID)
}
}
func TestCreateUserTrack(t *testing.T) {
s, err := loadSeeds()
if err != nil {
t.Fatalf("loadSeeds: %v", err)
}
defer s.Stop()
ctx := context.Background()
tr, err := s.CreateTrack(ctx, CreateTrackOptions{
Name: "My Garage",
AuthorID: "driver-1",
Visibility: VisibilityPublic,
LengthM: 3.5,
WidthM: 2.2,
Surface: SurfaceTile,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if tr.Visibility != VisibilityPublic {
t.Errorf("got visibility %q", tr.Visibility)
}
if tr.AuthorID != "driver-1" {
t.Errorf("got author %q", tr.AuthorID)
}
if tr.ID != "my-garage" {
t.Errorf("slug: got %q", tr.ID)
}
}
func TestCreateRejectsOversizedTrack(t *testing.T) {
s, _ := loadSeeds()
defer s.Stop()
ctx := context.Background()
_, err := s.CreateTrack(ctx, CreateTrackOptions{
Name: "Too Big",
LengthM: 6.0,
WidthM: 4.0,
})
if err == nil {
t.Fatal("expected error for oversized track")
}
}
func TestUpdateSystemTrackFails(t *testing.T) {
s, _ := loadSeeds()
defer s.Stop()
ctx := context.Background()
vis := VisibilityPublic
_, err := s.UpdateTrack(ctx, "monaco", UpdateTrackOptions{Visibility: &vis})
if err == nil {
t.Fatal("expected error when mutating system track")
}
}
func TestDeleteUserTrack(t *testing.T) {
s, _ := loadSeeds()
defer s.Stop()
ctx := context.Background()
tr, _ := s.CreateTrack(ctx, CreateTrackOptions{Name: "Temp", Visibility: VisibilityPrivate})
if err := s.DeleteTrack(ctx, tr.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, ok := s.GetTrack(tr.ID); ok {
t.Fatal("track still present")
}
}
func TestDeleteSystemTrackFails(t *testing.T) {
s, _ := loadSeeds()
defer s.Stop()
ctx := context.Background()
if err := s.DeleteTrack(ctx, "monaco"); err == nil {
t.Fatal("expected error when deleting system track")
}
}
func TestCreateAndUpdateCar(t *testing.T) {
s, _ := loadSeeds()
defer s.Stop()
ctx := context.Background()
car, err := s.CreateCar(ctx, CreateCarOptions{
Name: "Bumblebee",
OwnerID: "driver-1",
LengthMm: 78,
WidthMm: 34,
TopSpeedMs: 4.5,
ColorHex: "#d29922",
})
if err != nil {
t.Fatalf("create: %v", err)
}
if car.ID != "bumblebee" {
t.Errorf("slug: got %q", car.ID)
}
color := "#3fb950"
updated, err := s.UpdateCar(ctx, car.ID, UpdateCarOptions{ColorHex: &color})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.ColorHex != color {
t.Errorf("got color %q", updated.ColorHex)
}
}
func TestCreateCarRejectsInvalidDims(t *testing.T) {
s, _ := loadSeeds()
defer s.Stop()
ctx := context.Background()
_, err := s.CreateCar(ctx, CreateCarOptions{Name: "Tiny", LengthMm: 10, WidthMm: 5})
if err == nil {
t.Fatal("expected error for too-small car")
}
_, err = s.CreateCar(ctx, CreateCarOptions{Name: "Big", LengthMm: 500, WidthMm: 200})
if err == nil {
t.Fatal("expected error for too-big car")
}
}
func TestSubscribeReceivesChange(t *testing.T) {
s, _ := loadSeeds()
defer s.Stop()
ctx := context.Background()
ch, cancel := s.Subscribe()
defer cancel()
<-ch // drain initial snapshot
_, err := s.CreateTrack(ctx, CreateTrackOptions{Name: "E2E", Visibility: VisibilityPublic})
if err != nil {
t.Fatalf("create: %v", err)
}
select {
case ev := <-ch:
if ev.Kind != EventTrackUpsert {
t.Fatalf("kind=%s", ev.Kind)
}
if ev.TrackID != "e2e" {
t.Errorf("track_id=%s", ev.TrackID)
}
case <-time.After(time.Second):
t.Fatal("no event")
}
}
func TestStatsCounters(t *testing.T) {
s, _ := loadSeeds()
defer s.Stop()
st := s.Stats()
if st.TracksSystem < 5 {
t.Errorf("expected at least 5 system tracks, got %d", st.TracksSystem)
}
if st.CarsSystem < 5 {
t.Errorf("expected at least 5 system cars, got %d", st.CarsSystem)
}
}
func TestConcurrentCreatesSafe(t *testing.T) {
store := newMemoryStore()
s, err := NewService(context.Background(), store)
if err != nil {
t.Fatalf("new: %v", err)
}
defer s.Stop()
ctx := context.Background()
const n = 20
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func(i int) {
defer wg.Done()
name := "Concurrent"
_, _ = s.CreateTrack(ctx, CreateTrackOptions{
Name: name,
LengthM: 2.0,
WidthM: 1.2,
})
}(i)
}
wg.Wait()
if got := len(s.ListTracks()); got != 1 {
t.Errorf("expected 1 track, got %d", got)
}
}
func TestSetCarStatsUpdatesFields(t *testing.T) {
s, _ := loadSeeds()
defer s.Stop()
ctx := context.Background()
err := s.SetCarStats(ctx, "f1-2024-redbull-rb20", func(c *CarMeta) {
c.TotalLaps = 12
c.BestLapMs = 9450
c.TotalDistanceM = 320.5
})
if err != nil {
t.Fatalf("set stats: %v", err)
}
got, _ := s.GetCar("f1-2024-redbull-rb20")
if got.TotalLaps != 12 || got.BestLapMs != 9450 || got.TotalDistanceM != 320.5 {
t.Errorf("stats not updated: %+v", got)
}
}
func TestServiceWithoutStore(t *testing.T) {
s, err := NewService(context.Background(), nil)
if err != nil {
t.Fatalf("new: %v", err)
}
defer s.Stop()
ctx := context.Background()
_, err = s.CreateTrack(ctx, CreateTrackOptions{Name: "X", LengthM: 1.5, WidthM: 1.0})
if err == nil {
t.Fatal("expected error when store is nil")
}
}
@@ -0,0 +1,614 @@
// Package seeddefs holds the canonical seed definitions for tracks and
// cars. It lives in its own package (no postgres dependencies) so both
// the catalog tests and the scripts/genseed SQL generator can consume
// the same source-of-truth data.
package seeddefs
import "math"
// Default tracks and cars shipped with the PoC. All entries are
// visibility=system and therefore immutable from the API.
//
// Tracks: five real F1 circuits (2024 calendar), simplified to fit a
// 4.5 × 3.2 m bounding box at 1/27 scale. Each track keeps a
// recognisable shape and characteristic corners.
//
// Cars: five real F1 2024 teams. Length / width / weight are real
// (1/24 scale), power figures are real (full-scale 1.6L V6 hybrid
// turbo, ~1000 hp combined MGU-K + ICE).
func DefaultTracks() []Track {
return []Track{
Monaco(),
BarcelonaCatalunya(),
AustriaRedBullRing(),
GreatBritainSilverstone(),
BelgiumSpa(),
}
}
func DefaultCars() []Car {
return []Car{
RedBullRB20(),
FerrariSF24(),
McLarenMCL38(),
MercedesW15(),
AstonMartinAMR24(),
}
}
// ---------------------------------------------------------------------------
// Tracks
//
// Coordinates: lower-left origin, metres. Bounds must fit inside a
// 4.5 × 3.2 m room.
//
// Conventions:
// - closed loop, samples[i] and samples[0] are not duplicated
// - speed_ms is a suggested cornering speed (lower = tighter)
// - curvature = 1/radius; 0 = straight
// ---------------------------------------------------------------------------
// Monaco — narrow street circuit, hairpins, tight corners.
// Length ≈ 3.337 km IRL → simplified to 4.0 × 2.6 m.
func Monaco() Track {
const (
lengthM = 4.0
widthM = 2.6
)
// Famous corners: Sainte-Devote, Casino Square, Mirabeau, Loews hairpin,
// Portier, Tunnel, Nouvelle chicane, Tabac, Swimming pool, Rascasse, Anthony Noghes.
// Lower speeds through Monaco → 1.8..3.2 m/s.
wps := []Waypoint{
// Start/finish straight (Sainte-Devote approach).
{X: 0.4, Y: 2.2, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0},
{X: 1.1, Y: 2.2, HeadingRad: 0, SpeedMs: 3.0, Curvature: 0},
// Sainte-Devote (right kink).
{X: 1.5, Y: 2.05, HeadingRad: -0.4, SpeedMs: 2.4, Curvature: 1.2},
{X: 1.7, Y: 1.85, HeadingRad: -0.9, SpeedMs: 2.2, Curvature: 1.8},
// Up the hill to Casino / Massenet.
{X: 1.55, Y: 1.55, HeadingRad: -1.6, SpeedMs: 2.0, Curvature: 1.4},
{X: 1.25, Y: 1.4, HeadingRad: -2.4, SpeedMs: 2.1, Curvature: 1.2},
// Casino Square (slow left).
{X: 0.95, Y: 1.55, HeadingRad: math.Pi, SpeedMs: 1.9, Curvature: 1.6},
// Mirabeau hairpin (right).
{X: 0.75, Y: 1.85, HeadingRad: 2.0, SpeedMs: 1.8, Curvature: 2.2},
// Loews hairpin (tight left).
{X: 0.95, Y: 2.15, HeadingRad: 2.6, SpeedMs: 1.8, Curvature: 2.6},
{X: 1.2, Y: 2.4, HeadingRad: -2.8, SpeedMs: 2.1, Curvature: 1.4},
// Portier (right kink onto the straight).
{X: 1.6, Y: 2.5, HeadingRad: -3.0, SpeedMs: 2.4, Curvature: 1.0},
// Tunnel straight.
{X: 2.6, Y: 2.5, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0},
{X: 3.4, Y: 2.5, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0},
// Nouvelle chicane (fast left-right).
{X: 3.6, Y: 2.25, HeadingRad: -0.5, SpeedMs: 2.6, Curvature: 1.5},
{X: 3.55, Y: 1.95, HeadingRad: 0.4, SpeedMs: 2.6, Curvature: 1.5},
// Tabac (right).
{X: 3.3, Y: 1.75, HeadingRad: 1.0, SpeedMs: 2.3, Curvature: 1.4},
// Swimming pool (left-right-left).
{X: 2.95, Y: 1.55, HeadingRad: 1.7, SpeedMs: 2.1, Curvature: 1.8},
{X: 2.6, Y: 1.65, HeadingRad: 2.6, SpeedMs: 2.1, Curvature: 1.8},
{X: 2.4, Y: 1.9, HeadingRad: -2.7, SpeedMs: 2.3, Curvature: 1.4},
// Rascasse (left).
{X: 2.4, Y: 2.2, HeadingRad: -3.0, SpeedMs: 2.2, Curvature: 1.6},
// Anthony Noghes (left onto pit straight).
{X: 2.6, Y: 2.4, HeadingRad: 2.8, SpeedMs: 2.5, Curvature: 1.0},
// Pit straight (back to start).
{X: 3.2, Y: 2.4, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0},
{X: 3.7, Y: 2.4, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0},
{X: 3.95, Y: 2.3, HeadingRad: -0.3, SpeedMs: 2.8, Curvature: 0.8},
}
b := ComputeBounds(wps)
return Track{
ID: "monaco",
Name: "Monaco",
Description: "Circuit de Monaco. Narrow street circuit, tight hairpins, no room for error.",
AuthorID: "system",
Visibility: "system",
Scale: 27,
LengthM: b.LengthM,
WidthM: b.WidthM,
LaneWidthM: 0.18,
Surface: "tile",
Tags: []string{"f1", "street", "tight", "slow", "monaco"},
Centerline: wps,
Bounds: b,
BestLapHolder: "Verstappen",
}
}
// BarcelonaCatalunya — mix of high-speed and technical, used for testing.
// Length ≈ 4.675 km IRL → 4.3 × 2.9 m.
func BarcelonaCatalunya() Track {
const (
lengthM = 4.3
widthM = 2.9
)
wps := []Waypoint{
// Main straight.
{X: 0.3, Y: 0.4, HeadingRad: 0, SpeedMs: 4.5, Curvature: 0},
{X: 1.4, Y: 0.4, HeadingRad: 0, SpeedMs: 4.5, Curvature: 0},
// Turn 1 — Elf (right hairpin).
{X: 1.7, Y: 0.65, HeadingRad: math.Pi / 2, SpeedMs: 2.2, Curvature: 1.6},
{X: 1.5, Y: 0.95, HeadingRad: math.Pi, SpeedMs: 2.0, Curvature: 2.0},
// Turn 2-3 — right kink.
{X: 1.2, Y: 1.1, HeadingRad: math.Pi + 0.3, SpeedMs: 2.6, Curvature: 1.0},
{X: 0.95, Y: 1.0, HeadingRad: math.Pi + 0.9, SpeedMs: 2.6, Curvature: 1.0},
// Turn 4 — Repsol (left).
{X: 0.8, Y: 1.2, HeadingRad: -math.Pi / 2, SpeedMs: 2.4, Curvature: 1.2},
// Turn 5 — fast right.
{X: 1.05, Y: 1.5, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0.7},
// Turn 7-8 — slow left-right.
{X: 1.3, Y: 1.65, HeadingRad: math.Pi / 2, SpeedMs: 2.4, Curvature: 1.0},
{X: 1.15, Y: 1.85, HeadingRad: math.Pi, SpeedMs: 2.4, Curvature: 1.0},
// Turn 9-10 — Campsa (fast right).
{X: 1.4, Y: 2.0, HeadingRad: math.Pi - 0.5, SpeedMs: 3.0, Curvature: 0.7},
{X: 1.7, Y: 2.0, HeadingRad: math.Pi - 1.0, SpeedMs: 3.0, Curvature: 0.7},
// Turn 12-13 — long right.
{X: 1.95, Y: 2.2, HeadingRad: math.Pi / 2, SpeedMs: 2.8, Curvature: 0.5},
{X: 1.65, Y: 2.5, HeadingRad: 0.3, SpeedMs: 2.8, Curvature: 0.5},
// Turn 14-15 — fast right curve.
{X: 2.2, Y: 2.7, HeadingRad: 0.7, SpeedMs: 3.2, Curvature: 0.5},
{X: 2.8, Y: 2.7, HeadingRad: 1.1, SpeedMs: 3.4, Curvature: 0.5},
// Turn 16 — La Caixa (right hairpin).
{X: 3.1, Y: 2.5, HeadingRad: math.Pi / 2 + 0.4, SpeedMs: 2.2, Curvature: 1.4},
{X: 2.95, Y: 2.2, HeadingRad: math.Pi + 0.4, SpeedMs: 2.2, Curvature: 1.4},
// Back straight.
{X: 3.3, Y: 1.9, HeadingRad: 0, SpeedMs: 4.0, Curvature: 0},
{X: 4.0, Y: 1.9, HeadingRad: 0, SpeedMs: 4.0, Curvature: 0},
// Turn 16bis-17 (chicane before start).
{X: 4.05, Y: 1.6, HeadingRad: -math.Pi / 2, SpeedMs: 2.4, Curvature: 1.4},
{X: 3.8, Y: 1.4, HeadingRad: math.Pi, SpeedMs: 2.4, Curvature: 1.4},
{X: 3.5, Y: 1.5, HeadingRad: math.Pi + math.Pi/4, SpeedMs: 2.6, Curvature: 1.0},
// Approach to start.
{X: 3.1, Y: 1.25, HeadingRad: -math.Pi / 2 - 0.4, SpeedMs: 3.0, Curvature: 0.7},
{X: 2.7, Y: 1.0, HeadingRad: -math.Pi / 2 - 0.9, SpeedMs: 3.0, Curvature: 0.7},
{X: 2.3, Y: 0.7, HeadingRad: math.Pi + 0.4, SpeedMs: 3.4, Curvature: 0.5},
{X: 1.6, Y: 0.5, HeadingRad: math.Pi + 0.2, SpeedMs: 3.6, Curvature: 0.4},
{X: 0.9, Y: 0.42, HeadingRad: 0, SpeedMs: 4.0, Curvature: 0},
}
b := ComputeBounds(wps)
return Track{
ID: "barcelona-catalunya",
Name: "Barcelona-Catalunya",
Description: "Circuit de Barcelona-Catalunya. Mix of high-speed corners and technical chicanes.",
AuthorID: "system",
Visibility: "system",
Scale: 27,
LengthM: b.LengthM,
WidthM: b.WidthM,
LaneWidthM: 0.18,
Surface: "tile",
Tags: []string{"f1", "balanced", "testing", "barcelona"},
Centerline: wps,
Bounds: b,
BestLapHolder: "Verstappen",
}
}
// AustriaRedBullRing — short, fast, lots of elevation IRL.
// Length ≈ 4.318 km IRL → 4.0 × 2.4 m.
func AustriaRedBullRing() Track {
const (
lengthM = 4.0
widthM = 2.4
)
wps := []Waypoint{
// Main straight.
{X: 0.3, Y: 0.4, HeadingRad: 0, SpeedMs: 4.8, Curvature: 0},
{X: 1.6, Y: 0.4, HeadingRad: 0, SpeedMs: 4.8, Curvature: 0},
// Turn 1 — right hairpin.
{X: 1.85, Y: 0.65, HeadingRad: math.Pi / 2, SpeedMs: 2.0, Curvature: 1.8},
{X: 1.6, Y: 0.95, HeadingRad: math.Pi, SpeedMs: 2.0, Curvature: 1.8},
// Turn 2 — fast right.
{X: 1.3, Y: 1.0, HeadingRad: math.Pi + 0.5, SpeedMs: 3.0, Curvature: 0.7},
// Turn 3 — left.
{X: 1.05, Y: 1.2, HeadingRad: -math.Pi / 2, SpeedMs: 2.6, Curvature: 1.0},
// Turn 4 — Remus (fast uphill right).
{X: 1.4, Y: 1.4, HeadingRad: 0, SpeedMs: 3.2, Curvature: 0.5},
{X: 1.85, Y: 1.4, HeadingRad: math.Pi / 2 + 0.2, SpeedMs: 3.2, Curvature: 0.5},
// Turn 5-6 — fast right-left.
{X: 2.1, Y: 1.65, HeadingRad: math.Pi, SpeedMs: 3.0, Curvature: 0.7},
{X: 2.0, Y: 1.85, HeadingRad: -math.Pi / 2, SpeedMs: 3.0, Curvature: 0.7},
// Turn 7 — right hairpin.
{X: 2.25, Y: 2.05, HeadingRad: 0, SpeedMs: 2.0, Curvature: 1.8},
{X: 2.4, Y: 2.0, HeadingRad: math.Pi / 2, SpeedMs: 2.0, Curvature: 1.8},
// Turn 8 — left hairpin.
{X: 2.4, Y: 2.2, HeadingRad: math.Pi, SpeedMs: 2.0, Curvature: 1.8},
{X: 2.2, Y: 2.3, HeadingRad: -math.Pi / 2, SpeedMs: 2.0, Curvature: 1.8},
// Turn 9 — back straight start (left kink).
{X: 2.55, Y: 2.4, HeadingRad: -0.2, SpeedMs: 3.4, Curvature: 0.4},
{X: 3.0, Y: 2.4, HeadingRad: 0, SpeedMs: 4.0, Curvature: 0},
{X: 3.5, Y: 2.4, HeadingRad: 0, SpeedMs: 4.5, Curvature: 0},
// Turn 10 — fast right.
{X: 3.85, Y: 2.15, HeadingRad: math.Pi / 2 + 0.2, SpeedMs: 3.4, Curvature: 0.6},
// Back to pit straight via Schumacher esses.
{X: 3.6, Y: 1.8, HeadingRad: math.Pi, SpeedMs: 3.0, Curvature: 0.8},
{X: 3.25, Y: 1.85, HeadingRad: math.Pi + 0.6, SpeedMs: 3.0, Curvature: 0.8},
{X: 3.0, Y: 1.6, HeadingRad: -math.Pi / 2 - 0.4, SpeedMs: 3.2, Curvature: 0.6},
{X: 2.6, Y: 1.4, HeadingRad: -math.Pi, SpeedMs: 3.4, Curvature: 0.5},
{X: 2.0, Y: 1.2, HeadingRad: math.Pi - 0.5, SpeedMs: 3.6, Curvature: 0.4},
{X: 1.4, Y: 0.8, HeadingRad: math.Pi - 0.2, SpeedMs: 3.8, Curvature: 0.3},
{X: 0.8, Y: 0.5, HeadingRad: 0, SpeedMs: 4.2, Curvature: 0.2},
}
b := ComputeBounds(wps)
return Track{
ID: "austria-redbull-ring",
Name: "Austria (Red Bull Ring)",
Description: "Red Bull Ring. Short, fast, three straights separated by hairpins.",
AuthorID: "system",
Visibility: "system",
Scale: 27,
LengthM: b.LengthM,
WidthM: b.WidthM,
LaneWidthM: 0.18,
Surface: "wood",
Tags: []string{"f1", "short", "fast", "austria"},
Centerline: wps,
Bounds: b,
BestLapHolder: "Verstappen",
}
}
// GreatBritainSilverstone — fast, flowing, essex corners.
// Length ≈ 5.891 km IRL → 4.4 × 3.0 m.
func GreatBritainSilverstone() Track {
const (
lengthM = 4.4
widthM = 3.0
)
wps := []Waypoint{
// Main straight (Wellington / pit straight), bottom of room.
{X: 0.3, Y: 0.3, HeadingRad: 0, SpeedMs: 5.0, Curvature: 0},
{X: 2.4, Y: 0.3, HeadingRad: 0, SpeedMs: 5.0, Curvature: 0},
// Turn 1 — Abbey (right).
{X: 2.8, Y: 0.45, HeadingRad: math.Pi / 2, SpeedMs: 3.0, Curvature: 0.8},
// Turn 2 — Farm Curve (left).
{X: 2.6, Y: 0.8, HeadingRad: math.Pi, SpeedMs: 3.4, Curvature: 0.6},
// Turn 3 — Village (right kink).
{X: 2.95, Y: 1.05, HeadingRad: math.Pi / 2 + 0.5, SpeedMs: 3.2, Curvature: 0.7},
// Turn 4 — The Loop (right hairpin).
{X: 3.3, Y: 1.3, HeadingRad: 0, SpeedMs: 2.2, Curvature: 1.6},
{X: 3.6, Y: 1.15, HeadingRad: -math.Pi / 2, SpeedMs: 2.2, Curvature: 1.6},
// Turn 5 — Aintree (fast left).
{X: 3.6, Y: 0.8, HeadingRad: -math.Pi, SpeedMs: 3.0, Curvature: 0.7},
// Turn 6 — Brooklands (right).
{X: 3.85, Y: 0.55, HeadingRad: -math.Pi / 2, SpeedMs: 2.6, Curvature: 1.0},
// Turn 7 — Luffield (long right).
{X: 3.55, Y: 0.25, HeadingRad: 0, SpeedMs: 2.4, Curvature: 1.0},
{X: 3.25, Y: 0.25, HeadingRad: math.Pi / 2, SpeedMs: 2.4, Curvature: 1.0},
// Turn 8-9 — Woodcote (right kink onto pit straight).
{X: 3.05, Y: 0.55, HeadingRad: math.Pi / 2 + 0.3, SpeedMs: 3.4, Curvature: 0.5},
{X: 2.85, Y: 0.45, HeadingRad: math.Pi, SpeedMs: 4.0, Curvature: 0.3},
// (mid-pit straight into Copse corner)
{X: 1.8, Y: 0.3, HeadingRad: 0, SpeedMs: 4.6, Curvature: 0},
// Copse (fast right).
{X: 1.2, Y: 0.45, HeadingRad: -math.Pi / 2 - 0.2, SpeedMs: 3.8, Curvature: 0.4},
// Maggotts (right curve, swept).
{X: 1.0, Y: 0.85, HeadingRad: -math.Pi - 0.4, SpeedMs: 3.6, Curvature: 0.5},
// Beckets (left hairpin-ish).
{X: 0.7, Y: 1.15, HeadingRad: math.Pi / 2 + 0.3, SpeedMs: 2.4, Curvature: 1.2},
{X: 0.5, Y: 0.9, HeadingRad: 0, SpeedMs: 2.6, Curvature: 0.9},
// Chapel (right kink).
{X: 0.7, Y: 0.65, HeadingRad: -math.Pi / 2, SpeedMs: 3.0, Curvature: 0.7},
// Hangar (left).
{X: 0.45, Y: 0.45, HeadingRad: -math.Pi - 0.4, SpeedMs: 2.6, Curvature: 1.0},
// Stowe (right).
{X: 0.75, Y: 1.0, HeadingRad: math.Pi / 2 - 0.4, SpeedMs: 2.4, Curvature: 1.0},
// Vale (left kink).
{X: 0.5, Y: 1.5, HeadingRad: -math.Pi - 0.4, SpeedMs: 2.6, Curvature: 0.8},
// Club (right kink onto Wellington).
{X: 0.3, Y: 1.15, HeadingRad: -math.Pi / 2, SpeedMs: 2.4, Curvature: 1.0},
// Top return straight + kink back to Wellington.
{X: 0.7, Y: 1.6, HeadingRad: math.Pi / 4, SpeedMs: 3.4, Curvature: 0.5},
{X: 2.0, Y: 1.95, HeadingRad: math.Pi / 2 + 0.4, SpeedMs: 3.0, Curvature: 0.7},
{X: 3.0, Y: 2.4, HeadingRad: math.Pi + 0.2, SpeedMs: 3.4, Curvature: 0.4},
{X: 2.4, Y: 2.65, HeadingRad: -math.Pi / 2 - 0.3, SpeedMs: 4.0, Curvature: 0.3},
{X: 1.4, Y: 2.6, HeadingRad: -math.Pi, SpeedMs: 4.4, Curvature: 0.2},
{X: 0.5, Y: 2.3, HeadingRad: -math.Pi + 0.3, SpeedMs: 4.8, Curvature: 0.2},
{X: 0.3, Y: 1.7, HeadingRad: -math.Pi / 2 + 0.3, SpeedMs: 5.0, Curvature: 0.1},
}
b := ComputeBounds(wps)
return Track{
ID: "great-britain-silverstone",
Name: "Great Britain (Silverstone)",
Description: "Silverstone Circuit. Fast, flowing corners; home of British motorsport.",
AuthorID: "system",
Visibility: "system",
Scale: 27,
LengthM: b.LengthM,
WidthM: b.WidthM,
LaneWidthM: 0.18,
Surface: "carpet",
Tags: []string{"f1", "fast", "flowing", "silverstone"},
Centerline: wps,
Bounds: b,
BestLapHolder: "Hamilton",
}
}
// BelgiumSpa — long, fast, Eau Rouge / Raidillon, varied weather.
// Length ≈ 7.004 km IRL → 4.5 × 3.0 m.
func BelgiumSpa() Track {
const (
lengthM = 4.5
widthM = 3.0
)
wps := []Waypoint{
// Main straight (La Source exit).
{X: 0.4, Y: 0.5, HeadingRad: 0, SpeedMs: 4.8, Curvature: 0},
{X: 1.6, Y: 0.5, HeadingRad: 0, SpeedMs: 4.8, Curvature: 0},
// Eau Rouge / Raidillon (fast uphill left-right-left).
{X: 1.95, Y: 0.6, HeadingRad: 0.3, SpeedMs: 4.2, Curvature: 0.5},
{X: 2.2, Y: 0.95, HeadingRad: 0.9, SpeedMs: 4.0, Curvature: 0.6},
{X: 2.1, Y: 1.3, HeadingRad: math.Pi + 0.4, SpeedMs: 4.0, Curvature: 0.6},
// Kemmel straight.
{X: 2.7, Y: 1.5, HeadingRad: 0, SpeedMs: 5.0, Curvature: 0},
{X: 3.5, Y: 1.5, HeadingRad: 0, SpeedMs: 5.0, Curvature: 0},
// Les Combes (chicane).
{X: 3.85, Y: 1.3, HeadingRad: -math.Pi / 2, SpeedMs: 2.4, Curvature: 1.4},
{X: 3.6, Y: 1.1, HeadingRad: math.Pi + 0.3, SpeedMs: 2.6, Curvature: 1.2},
{X: 3.3, Y: 1.25, HeadingRad: math.Pi + 0.9, SpeedMs: 2.6, Curvature: 1.2},
// Malmedy (fast right sweep).
{X: 3.5, Y: 1.55, HeadingRad: math.Pi / 2, SpeedMs: 3.6, Curvature: 0.4},
// Rivage (right hairpin).
{X: 3.25, Y: 1.85, HeadingRad: 0, SpeedMs: 2.2, Curvature: 1.6},
{X: 3.05, Y: 1.7, HeadingRad: -math.Pi / 2, SpeedMs: 2.4, Curvature: 1.4},
// Campus (left kink).
{X: 3.25, Y: 1.5, HeadingRad: 0, SpeedMs: 3.0, Curvature: 0.7},
// Stavelot (fast right).
{X: 3.7, Y: 1.65, HeadingRad: math.Pi / 2 + 0.3, SpeedMs: 3.4, Curvature: 0.5},
// Up to Blanchimont (long right).
{X: 4.1, Y: 2.0, HeadingRad: math.Pi / 2 + 0.6, SpeedMs: 4.0, Curvature: 0.3},
// Blanchimont (very fast right kink).
{X: 4.25, Y: 2.4, HeadingRad: math.Pi + 0.4, SpeedMs: 4.6, Curvature: 0.3},
// Bus stop chicane (slow left-right).
{X: 4.0, Y: 2.7, HeadingRad: -math.Pi / 2 - 0.3, SpeedMs: 2.4, Curvature: 1.4},
{X: 3.7, Y: 2.55, HeadingRad: math.Pi, SpeedMs: 2.4, Curvature: 1.4},
{X: 3.55, Y: 2.75, HeadingRad: math.Pi + 0.7, SpeedMs: 2.4, Curvature: 1.4},
// Back to La Source hairpin.
{X: 3.05, Y: 2.7, HeadingRad: -math.Pi / 2, SpeedMs: 2.0, Curvature: 1.8},
{X: 2.85, Y: 2.45, HeadingRad: 0, SpeedMs: 2.0, Curvature: 1.8},
// Pit straight.
{X: 2.0, Y: 2.2, HeadingRad: math.Pi - 0.3, SpeedMs: 3.6, Curvature: 0.3},
{X: 1.2, Y: 1.6, HeadingRad: math.Pi - 0.6, SpeedMs: 4.0, Curvature: 0.2},
{X: 0.6, Y: 1.05, HeadingRad: math.Pi - 0.3, SpeedMs: 4.4, Curvature: 0.2},
}
b := ComputeBounds(wps)
return Track{
ID: "belgium-spa",
Name: "Belgium (Spa-Francorchamps)",
Description: "Spa-Francorchamps. Long, fast, legendary Eau Rouge / Raidillon.",
AuthorID: "system",
Visibility: "system",
Scale: 27,
LengthM: b.LengthM,
WidthM: b.WidthM,
LaneWidthM: 0.18,
Surface: "carpet",
Tags: []string{"f1", "long", "fast", "spa"},
Centerline: wps,
Bounds: b,
BestLapHolder: "Verstappen",
}
}
// ---------------------------------------------------------------------------
// Cars — F1 2024 constructors
//
// Real cars are 1:1 with ~5.7 × 2.0 × 0.95 m, ~798 kg, ~1000 hp.
// We model them at 1/24 scale so the largest dimension (~238 mm) still
// fits the catalog physical-envelope check.
// ---------------------------------------------------------------------------
// RedBullRB20 — Oracle Red Bull Racing, Verstappen / Perez.
// 2024: 9 wins (last year of dominant era), Constructors' champion.
func RedBullRB20() Car {
return Car{
ID: "f1-2024-redbull-rb20",
Name: "Oracle Red Bull Racing RB20",
OwnerID: "system",
Visibility: "system",
Scale: 24,
LengthMm: 238, // 5.7m IRL
WidthMm: 83, // 2.0m IRL
HeightMm: 40, // 0.95m IRL
WeightG: 33, // 798 kg IRL
Chassis: Chassis{
Model: "RB20 carbon monocoque",
Material: "carbon-fibre",
Printed: false,
WheelbaseMm: 150, // 3.6m IRL
TrackMm: 75, // 1.8m IRL
},
Motor: Motor{
Kind: "turbo-hybrid",
Class: "F1 PU",
KV: 0,
PowerW: 746000, // ~1000 hp ICE + MGU-K, peak 746 kW
},
Battery: Battery{
VoltageV: 0,
CapacityMah: 0,
Cells: 0,
Chemistry: "li-ion MGU-K",
},
Drive: "2WD",
TopSpeedMs: 95, // ~340 km/h
ColorHex: "#1e3a8a", // dark blue + red/yellow accents
Active: true,
TotalRaces: 0,
TotalLaps: 0,
BestLapMs: 0,
}
}
// FerrariSF24 — Scuderia Ferrari, Leclerc / Sainz.
func FerrariSF24() Car {
return Car{
ID: "f1-2024-ferrari-sf24",
Name: "Scuderia Ferrari SF-24",
OwnerID: "system",
Visibility: "system",
Scale: 24,
LengthMm: 238,
WidthMm: 83,
HeightMm: 40,
WeightG: 33,
Chassis: Chassis{
Model: "SF-24 carbon monocoque",
Material: "carbon-fibre",
Printed: false,
WheelbaseMm: 150,
TrackMm: 75,
},
Motor: Motor{
Kind: "turbo-hybrid",
Class: "F1 PU 066/12",
KV: 0,
PowerW: 746000,
},
Battery: Battery{
Chemistry: "li-ion MGU-K",
},
Drive: "2WD",
TopSpeedMs: 94,
ColorHex: "#dc2626", // rosso corsa
Active: true,
}
}
// McLarenMCL38 — McLaren Racing, Norris / Piastri. 2024 Constructors' runner-up.
func McLarenMCL38() Car {
return Car{
ID: "f1-2024-mclaren-mcl38",
Name: "McLaren MCL38",
OwnerID: "system",
Visibility: "system",
Scale: 24,
LengthMm: 238,
WidthMm: 83,
HeightMm: 40,
WeightG: 33,
Chassis: Chassis{
Model: "MCL38 carbon monocoque",
Material: "carbon-fibre",
Printed: false,
WheelbaseMm: 150,
TrackMm: 75,
},
Motor: Motor{
Kind: "turbo-hybrid",
Class: "F1 PU Mercedes-derived",
KV: 0,
PowerW: 746000,
},
Battery: Battery{
Chemistry: "li-ion MGU-K",
},
Drive: "2WD",
TopSpeedMs: 94,
ColorHex: "#ea580c", // papaya orange
Active: true,
}
}
// MercedesW15 — Mercedes-AMG Petronas, Russell / Hamilton (last year of Hamilton at Mercedes).
func MercedesW15() Car {
return Car{
ID: "f1-2024-mercedes-w15",
Name: "Mercedes-AMG W15",
OwnerID: "system",
Visibility: "system",
Scale: 24,
LengthMm: 238,
WidthMm: 83,
HeightMm: 40,
WeightG: 33,
Chassis: Chassis{
Model: "W15 carbon monocoque",
Material: "carbon-fibre",
Printed: false,
WheelbaseMm: 150,
TrackMm: 75,
},
Motor: Motor{
Kind: "turbo-hybrid",
Class: "F1 PU M15",
KV: 0,
PowerW: 746000,
},
Battery: Battery{
Chemistry: "li-ion MGU-K",
},
Drive: "2WD",
TopSpeedMs: 94,
ColorHex: "#06b6d4", // silver-petronas cyan
Active: true,
}
}
// AstonMartinAMR24 — Aston Martin Aramco, Alonso / Stroll.
func AstonMartinAMR24() Car {
return Car{
ID: "f1-2024-astonmartin-amr24",
Name: "Aston Martin Aramco AMR24",
OwnerID: "system",
Visibility: "system",
Scale: 24,
LengthMm: 238,
WidthMm: 83,
HeightMm: 40,
WeightG: 33,
Chassis: Chassis{
Model: "AMR24 carbon monocoque",
Material: "carbon-fibre",
Printed: false,
WheelbaseMm: 150,
TrackMm: 75,
},
Motor: Motor{
Kind: "turbo-hybrid",
Class: "F1 PU Mercedes-derived",
KV: 0,
PowerW: 746000,
},
Battery: Battery{
Chemistry: "li-ion MGU-K",
},
Drive: "2WD",
TopSpeedMs: 94,
ColorHex: "#16a34a", // racing green
Active: true,
}
}
// ComputeBounds derives a bounding box from a closed loop of waypoints.
func ComputeBounds(loop []Waypoint) Bounds {
if len(loop) == 0 {
return Bounds{}
}
b := Bounds{
MinX: loop[0].X,
MinY: loop[0].Y,
MaxX: loop[0].X,
MaxY: loop[0].Y,
}
for _, p := range loop[1:] {
if p.X < b.MinX {
b.MinX = p.X
}
if p.Y < b.MinY {
b.MinY = p.Y
}
if p.X > b.MaxX {
b.MaxX = p.X
}
if p.Y > b.MaxY {
b.MaxY = p.Y
}
}
b.LengthM = b.MaxX - b.MinX
b.WidthM = b.MaxY - b.MinY
return b
}
@@ -0,0 +1,40 @@
package seeddefs
import "testing"
func TestTracksFitInRoom(t *testing.T) {
const (
roomLen = 4.5
roomWid = 3.2
)
for _, tr := range DefaultTracks() {
if tr.Bounds.LengthM > roomLen {
t.Errorf("track %s: length %.3f m exceeds 4.5 m", tr.ID, tr.Bounds.LengthM)
}
if tr.Bounds.WidthM > roomWid {
t.Errorf("track %s: width %.3f m exceeds 3.2 m", tr.ID, tr.Bounds.WidthM)
}
if len(tr.Centerline) < 8 {
t.Errorf("track %s: too few waypoints (%d)", tr.ID, len(tr.Centerline))
}
t.Logf("%-30s %5.2fm x %5.2fm %2d waypoints", tr.ID,
tr.Bounds.LengthM, tr.Bounds.WidthM, len(tr.Centerline))
}
}
func TestCarsValid(t *testing.T) {
for _, c := range DefaultCars() {
if c.LengthMm < 200 || c.LengthMm > 260 {
t.Errorf("car %s: length %.0f mm out of F1 1/24 range (200..260)", c.ID, c.LengthMm)
}
if c.WidthMm < 70 || c.WidthMm > 100 {
t.Errorf("car %s: width %.0f mm out of F1 1/24 range", c.ID, c.WidthMm)
}
if c.Motor.PowerW < 500000 {
t.Errorf("car %s: power %.0f W too low for F1 PU", c.ID, c.Motor.PowerW)
}
t.Logf("%-35s %dx%dx%d mm, %d W, %d m/s", c.ID,
int(c.LengthMm), int(c.WidthMm), int(c.HeightMm),
int(c.Motor.PowerW), int(c.TopSpeedMs))
}
}
+84
View File
@@ -0,0 +1,84 @@
package seeddefs
// Waypoint mirrors catalog.Waypoint — duplicated here to keep the
// seeddefs package free of catalog (and thus postgres) imports.
type Waypoint struct {
X float64
Y float64
HeadingRad float64
SpeedMs float64
Curvature float64
}
type Bounds struct {
MinX, MinY, MaxX, MaxY float64
LengthM float64
WidthM float64
}
type Track struct {
ID string
Name string
Description string
AuthorID string
Visibility string
Scale int
LengthM float64
WidthM float64
LaneWidthM float64
Surface string
Tags []string
Centerline []Waypoint
Bounds Bounds
BestLapMs int64
BestLapHolder string
}
type Chassis struct {
Model string
Material string
Printed bool
WheelbaseMm float64
TrackMm float64
}
type Motor struct {
Kind string
Class string
KV int
PowerW float64
}
type Battery struct {
VoltageV float64
CapacityMah int
Cells int
Chemistry string
}
type Car struct {
ID string
Name string
OwnerID string
Visibility string
Scale int
LengthMm float64
WidthMm float64
HeightMm float64
WeightG float64
Chassis Chassis
Motor Motor
Battery Battery
Drive string
TopSpeedMs float64
ColorHex string
Active bool
TotalDistanceM float64
TotalRaces int
TotalLaps int
BestLapMs int64
}
+30
View File
@@ -0,0 +1,30 @@
package catalog
import (
"context"
)
// Store is the persistence layer used by Service. The runtime uses
// postgres.PgStore; tests use memoryStore (defined in catalog_test.go).
type Store interface {
// Load populates the in-memory cache at startup. Returns the full
// snapshot: tracks (with waypoints and tags) and cars.
Load(ctx context.Context) ([]TrackMeta, []CarMeta, error)
// UpsertTrack inserts or updates a track along with its waypoints and
// tags in a single transaction.
UpsertTrack(ctx context.Context, t TrackMeta) error
// DeleteTrack removes a track and all its waypoints / tags.
DeleteTrack(ctx context.Context, id string) error
// UpsertCar inserts or updates a car.
UpsertCar(ctx context.Context, c CarMeta) error
// DeleteCar removes a car by id.
DeleteCar(ctx context.Context, id string) error
// UpdateCarStats applies a partial mutation of read-mostly stats
// fields on a car. Used by the race engine hooks.
UpdateCarStats(ctx context.Context, id string, fn func(*CarMeta)) error
}
+75
View File
@@ -0,0 +1,75 @@
package catalog
import (
"context"
"sync"
)
// memoryStore is an in-process implementation of Store for tests and
// for early PoC runs without a database. It is goroutine-safe.
type memoryStore struct {
mu sync.Mutex
tracks map[TrackID]TrackMeta
cars map[CarID]CarMeta
}
func newMemoryStore() *memoryStore {
return &memoryStore{
tracks: make(map[TrackID]TrackMeta),
cars: make(map[CarID]CarMeta),
}
}
func (m *memoryStore) Load(_ context.Context) ([]TrackMeta, []CarMeta, error) {
m.mu.Lock()
defer m.mu.Unlock()
tracks := make([]TrackMeta, 0, len(m.tracks))
for _, t := range m.tracks {
tracks = append(tracks, t)
}
cars := make([]CarMeta, 0, len(m.cars))
for _, c := range m.cars {
cars = append(cars, c)
}
return tracks, cars, nil
}
func (m *memoryStore) UpsertTrack(_ context.Context, t TrackMeta) error {
m.mu.Lock()
defer m.mu.Unlock()
m.tracks[t.ID] = t
return nil
}
func (m *memoryStore) DeleteTrack(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.tracks, id)
return nil
}
func (m *memoryStore) UpsertCar(_ context.Context, c CarMeta) error {
m.mu.Lock()
defer m.mu.Unlock()
m.cars[c.ID] = c
return nil
}
func (m *memoryStore) DeleteCar(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.cars, id)
return nil
}
func (m *memoryStore) UpdateCarStats(_ context.Context, id string, fn func(*CarMeta)) error {
m.mu.Lock()
defer m.mu.Unlock()
c, ok := m.cars[id]
if !ok {
return ErrNotFound
}
fn(&c)
m.cars[id] = c
return nil
}
+546
View File
@@ -0,0 +1,546 @@
// Package catalog owns the static configuration entities: tracks and
// cars. Both are first-class resources that drivers browse, pick, and
// (in later phases) author.
//
// PoC: in-memory cache backed by a pluggable Store. The runtime uses
// postgres.PgStore so tracks / cars survive restarts. The cache is the
// source of truth for read paths; the Store is the source of truth for
// durability. Mutations go through the Store, then refresh the cache.
//
// Design constraints come from the user's apartment layout:
// - largest room: 4.46 m × 3.19 m (446 × 319 cm)
// - car scale: 1/27 (Compact)
// - printer: Bambu A1 (220×220×250 mm build volume)
//
// Tracks are sized to fit a 4.5 × 3.2 m bounding box with a safety
// margin, which keeps them physically realizable in the apartment.
// Cars are sized to a 1/27 touring envelope (~80 × 35 × 25 mm).
package catalog
import (
"context"
"errors"
"sync"
"sync/atomic"
"time"
)
// Visibility controls who can see / pick a track or car.
type Visibility string
const (
VisibilitySystem Visibility = "system" // built-in, immutable
VisibilityPublic Visibility = "public" // visible to everyone
VisibilityPrivate Visibility = "private" // owner-only
)
// Surface is the type of floor the track is laid on.
type Surface string
const (
SurfaceCarpet Surface = "carpet"
SurfaceTile Surface = "tile"
SurfaceWood Surface = "wood"
SurfacePaper Surface = "paper"
SurfaceMixed Surface = "mixed"
)
// Drivetrain of a car.
type Drivetrain string
const (
Drivetrain2WD Drivetrain = "2WD"
Drivetrain4WD Drivetrain = "4WD"
)
// Waypoint is one sample of the racing line of a track.
// The centerline is a closed loop: waypoint N wraps back to waypoint 0.
type Waypoint struct {
X float64 `json:"x"` // metres from track origin (lower-left)
Y float64 `json:"y"` // metres
HeadingRad float64 `json:"heading_rad"` // tangent angle, radians
SpeedMs float64 `json:"speed_ms"` // suggested cornering speed (m/s)
Curvature float64 `json:"curvature"` // 1/radius; 0 = straight
}
// Bounds are the bounding box of a track (metres, lower-left origin).
type Bounds struct {
MinX float64 `json:"min_x"`
MinY float64 `json:"min_y"`
MaxX float64 `json:"max_x"`
MaxY float64 `json:"max_y"`
LengthM float64 `json:"length_m"` // longest axis
WidthM float64 `json:"width_m"` // shortest axis
}
// TrackMeta describes one track.
type TrackMeta struct {
ID string `json:"id"` // kebab-case slug
Name string `json:"name"`
Description string `json:"description,omitempty"`
AuthorID string `json:"author_id"` // driver id or "system"
Visibility Visibility `json:"visibility"`
Scale int `json:"scale"` // nominal, e.g. 27 for 1/27
LengthM float64 `json:"length_m"` // physical length (bounding box long axis)
WidthM float64 `json:"width_m"` // physical width
LaneWidthM float64 `json:"lane_width_m"` // recommended lane width
Bounds Bounds `json:"bounds"` // computed bounding box
Surface Surface `json:"surface"`
Tags []string `json:"tags,omitempty"`
Centerline []Waypoint `json:"centerline"` // racing line
BestLapMs int64 `json:"best_lap_ms"` // 0 if no record
BestLapHolder string `json:"best_lap_holder,omitempty"`
CreatedMs int64 `json:"created_ms"`
UpdatedMs int64 `json:"updated_ms"`
}
// MotorSpec describes the electric motor.
type MotorSpec struct {
Kind string `json:"kind"` // brushed | brushless
Class string `json:"class"` // "130", "180", "280", "030-size"
KV int `json:"kv"` // brushless RPM/V; 0 for brushed
PowerW float64 `json:"power_w"` // peak watts
}
// BatterySpec describes the battery pack.
type BatterySpec struct {
VoltageV float64 `json:"voltage_v"` // nominal voltage
CapacityMah int `json:"capacity_mah"`
Cells int `json:"cells"` // 1S..6S
Chemistry string `json:"chemistry"` // "li-po" | "li-ion" | "nimh"
}
// ChassisSpec describes the chassis / body.
type ChassisSpec struct {
Model string `json:"model"` // free-form
Material string `json:"material"` // "pla" | "petg" | "abs" | "carbon"
Printed bool `json:"printed"` // 3D-printed body / chassis?
WheelbaseMm float64 `json:"wheelbase_mm"`
TrackMm float64 `json:"track_mm"` // front/rear track (avg)
}
// CarMeta describes one car (chassis + powertrain + visual + stats).
type CarMeta struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerID string `json:"owner_id"` // driver id or "system"
Visibility Visibility `json:"visibility"`
Scale int `json:"scale"` // 27 for 1/27
// Physical envelope (millimetres).
LengthMm float64 `json:"length_mm"`
WidthMm float64 `json:"width_mm"`
HeightMm float64 `json:"height_mm"`
WeightG float64 `json:"weight_g"`
// Specs.
Chassis ChassisSpec `json:"chassis"`
Motor MotorSpec `json:"motor"`
Battery BatterySpec `json:"battery"`
Drive Drivetrain `json:"drive"`
// Performance envelope.
TopSpeedMs float64 `json:"top_speed_ms"`
// Visual.
ColorHex string `json:"color_hex"`
AvatarURL string `json:"avatar_url,omitempty"`
// Lifecycle.
Active bool `json:"active"`
// Stats (read-mostly; mutated by race engine via SetStats).
TotalDistanceM float64 `json:"total_distance_m"`
TotalRaces int `json:"total_races"`
TotalLaps int `json:"total_laps"`
BestLapMs int64 `json:"best_lap_ms"`
CreatedMs int64 `json:"created_ms"`
UpdatedMs int64 `json:"updated_ms"`
}
// Snapshot is the immutable view broadcast to clients.
type Snapshot struct {
GeneratedMs int64 `json:"generated_ms"`
Version uint64 `json:"version"`
Tracks []TrackMeta `json:"tracks"`
Cars []CarMeta `json:"cars"`
}
// Stats are lightweight counters (for /api/catalog summary).
type Stats struct {
TracksTotal int `json:"tracks_total"`
TracksSystem int `json:"tracks_system"`
TracksPublic int `json:"tracks_public"`
TracksPrivate int `json:"tracks_private"`
CarsTotal int `json:"cars_total"`
CarsSystem int `json:"cars_system"`
CarsPublic int `json:"cars_public"`
CarsPrivate int `json:"cars_private"`
CarsActive int `json:"cars_active"`
}
// TrackID is a type alias to avoid stringly-typed APIs.
type TrackID = string
// CarID is a type alias to avoid stringly-typed APIs.
type CarID = string
// AuthorSystem is the author ID used for built-in resources.
const AuthorSystem = "system"
// EventKind tags the kind of change in an Event.
type EventKind string
const (
EventSnapshot EventKind = "snapshot"
EventTrackUpsert EventKind = "track_upsert"
EventTrackDelete EventKind = "track_delete"
EventCarUpsert EventKind = "car_upsert"
EventCarDelete EventKind = "car_delete"
)
// Event is the internal broadcast (not exported on the wire).
type Event struct {
Kind EventKind
TrackID TrackID
CarID CarID
Snapshot Snapshot
}
// Service owns the catalog cache. Mutations are persisted via Store and
// then reflected in the in-memory cache. Safe for concurrent use.
//
// Design note: a single Service holds both tracks and cars so the
// version counter is shared — clients can use it as a single ordering
// signal for the catalog feed.
type Service struct {
store Store
mu sync.RWMutex
tracks map[TrackID]*TrackMeta
cars map[CarID]*CarMeta
version atomic.Uint64
subsMu sync.RWMutex
subs map[chan Event]struct{}
stopCh chan struct{}
doneCh chan struct{}
}
// NewService creates a Service backed by the given Store and loads
// existing tracks / cars from it. The Store may be nil (then Load is
// skipped and the cache is empty) — useful for early unit tests.
func NewService(ctx context.Context, store Store) (*Service, error) {
s := &Service{
store: store,
tracks: make(map[TrackID]*TrackMeta),
cars: make(map[CarID]*CarMeta),
subs: make(map[chan Event]struct{}),
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
if store != nil {
tracks, cars, err := store.Load(ctx)
if err != nil {
return nil, err
}
for i := range tracks {
t := tracks[i]
s.normalizeTrack(&t)
s.tracks[t.ID] = &t
}
for i := range cars {
c := cars[i]
s.normalizeCar(&c)
s.cars[c.ID] = &c
}
}
return s, nil
}
// normalizeTrack fills computed fields (Bounds, CreatedMs, etc.) and
// validates minimal invariants.
func (s *Service) normalizeTrack(t *TrackMeta) {
if t.ID == "" {
return
}
if t.Scale == 0 {
t.Scale = 27
}
if t.Surface == "" {
t.Surface = SurfaceCarpet
}
if t.Visibility == "" {
t.Visibility = VisibilitySystem
}
if t.LaneWidthM == 0 {
t.LaneWidthM = 0.20
}
if t.AuthorID == "" {
t.AuthorID = AuthorSystem
}
if len(t.Centerline) == 0 {
t.Centerline = defaultCenterline(t.LengthM, t.WidthM)
}
// Recompute bounds from centerline if missing.
if t.Bounds.MaxX == 0 || t.Bounds.MaxY == 0 {
t.Bounds = computeBounds(t.Centerline)
t.LengthM = t.Bounds.LengthM
t.WidthM = t.Bounds.WidthM
}
now := time.Now().UnixMilli()
if t.CreatedMs == 0 {
t.CreatedMs = now
}
if t.UpdatedMs == 0 {
t.UpdatedMs = now
}
}
// normalizeCar fills defaults and timestamps.
func (s *Service) normalizeCar(c *CarMeta) {
if c.ID == "" {
return
}
if c.Scale == 0 {
c.Scale = 27
}
if c.Visibility == "" {
c.Visibility = VisibilitySystem
}
if c.Drive == "" {
c.Drive = Drivetrain2WD
}
if c.OwnerID == "" {
c.OwnerID = AuthorSystem
}
if c.ColorHex == "" {
c.ColorHex = "#ff6b1a"
}
if c.Battery.Chemistry == "" {
c.Battery.Chemistry = "li-po"
}
now := time.Now().UnixMilli()
if c.CreatedMs == 0 {
c.CreatedMs = now
}
if c.UpdatedMs == 0 {
c.UpdatedMs = now
}
}
// Subscribe returns a channel that receives catalog events. The caller
// MUST invoke the returned cancel func to release resources.
func (s *Service) Subscribe() (<-chan Event, func()) {
ch := make(chan Event, 16)
s.subsMu.Lock()
s.subs[ch] = struct{}{}
s.subsMu.Unlock()
// Push an initial snapshot so subscribers can render immediately.
ch <- Event{Kind: EventSnapshot, Snapshot: s.snapshot()}
return ch, func() {
s.subsMu.Lock()
if _, ok := s.subs[ch]; ok {
delete(s.subs, ch)
close(ch)
}
s.subsMu.Unlock()
}
}
// Stop terminates the service. Safe to call multiple times.
func (s *Service) Stop() {
select {
case <-s.stopCh:
return
default:
close(s.stopCh)
s.subsMu.Lock()
for ch := range s.subs {
delete(s.subs, ch)
close(ch)
}
s.subsMu.Unlock()
close(s.doneCh)
}
}
// Done blocks until Stop() is called.
func (s *Service) Done() <-chan struct{} { return s.doneCh }
// Snapshot returns an immutable copy of the catalog state.
func (s *Service) Snapshot() Snapshot { return s.snapshot() }
func (s *Service) snapshot() Snapshot {
s.mu.RLock()
defer s.mu.RUnlock()
out := Snapshot{
GeneratedMs: time.Now().UnixMilli(),
Version: s.version.Load(),
Tracks: make([]TrackMeta, 0, len(s.tracks)),
Cars: make([]CarMeta, 0, len(s.cars)),
}
for _, t := range s.tracks {
out.Tracks = append(out.Tracks, *t)
}
for _, c := range s.cars {
out.Cars = append(out.Cars, *c)
}
return out
}
// Stats returns counters grouped by visibility.
func (s *Service) Stats() Stats {
s.mu.RLock()
defer s.mu.RUnlock()
out := Stats{}
for _, t := range s.tracks {
out.TracksTotal++
switch t.Visibility {
case VisibilitySystem:
out.TracksSystem++
case VisibilityPublic:
out.TracksPublic++
case VisibilityPrivate:
out.TracksPrivate++
}
}
for _, c := range s.cars {
out.CarsTotal++
switch c.Visibility {
case VisibilitySystem:
out.CarsSystem++
case VisibilityPublic:
out.CarsPublic++
case VisibilityPrivate:
out.CarsPrivate++
}
if c.Active {
out.CarsActive++
}
}
return out
}
// ListTracks returns a copy of all tracks.
func (s *Service) ListTracks() []TrackMeta {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]TrackMeta, 0, len(s.tracks))
for _, t := range s.tracks {
out = append(out, *t)
}
return out
}
// ListCars returns a copy of all cars.
func (s *Service) ListCars() []CarMeta {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]CarMeta, 0, len(s.cars))
for _, c := range s.cars {
out = append(out, *c)
}
return out
}
// GetTrack returns a track by ID (by value, safe to mutate).
func (s *Service) GetTrack(id string) (TrackMeta, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.tracks[id]
if !ok {
return TrackMeta{}, false
}
return *t, true
}
// GetCar returns a car by ID (by value, safe to mutate).
func (s *Service) GetCar(id string) (CarMeta, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
c, ok := s.cars[id]
if !ok {
return CarMeta{}, false
}
return *c, true
}
// persistUpsertTrack writes a track through the Store and updates the
// cache. Used by all track write paths.
func (s *Service) persistUpsertTrack(ctx context.Context, t TrackMeta) error {
if s.store == nil {
return errors.New("catalog: store not configured")
}
s.normalizeTrack(&t)
if err := s.store.UpsertTrack(ctx, t); err != nil {
return err
}
s.mu.Lock()
s.tracks[t.ID] = &t
s.mu.Unlock()
s.bump(Event{Kind: EventTrackUpsert, TrackID: t.ID})
return nil
}
func (s *Service) persistDeleteTrack(ctx context.Context, id string) error {
if s.store == nil {
return errors.New("catalog: store not configured")
}
if err := s.store.DeleteTrack(ctx, id); err != nil {
return err
}
s.mu.Lock()
delete(s.tracks, id)
s.mu.Unlock()
s.bump(Event{Kind: EventTrackDelete, TrackID: id})
return nil
}
func (s *Service) persistUpsertCar(ctx context.Context, c CarMeta) error {
if s.store == nil {
return errors.New("catalog: store not configured")
}
s.normalizeCar(&c)
if err := s.store.UpsertCar(ctx, c); err != nil {
return err
}
s.mu.Lock()
s.cars[c.ID] = &c
s.mu.Unlock()
s.bump(Event{Kind: EventCarUpsert, CarID: c.ID})
return nil
}
func (s *Service) persistDeleteCar(ctx context.Context, id string) error {
if s.store == nil {
return errors.New("catalog: store not configured")
}
if err := s.store.DeleteCar(ctx, id); err != nil {
return err
}
s.mu.Lock()
delete(s.cars, id)
s.mu.Unlock()
s.bump(Event{Kind: EventCarDelete, CarID: id})
return nil
}
// bump increments the version and broadcasts. Caller MUST NOT hold s.mu.
func (s *Service) bump(ev Event) {
s.version.Add(1)
ev.Snapshot = s.snapshot()
s.subsMu.RLock()
defer s.subsMu.RUnlock()
for ch := range s.subs {
select {
case ch <- ev:
default:
// Slow subscriber — they'll re-sync via the next snapshot.
}
}
}