mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user