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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package clans
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// Service composes validation on top of PgStore.
|
||||
type Service struct {
|
||||
pg *PgStore
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService wires a service.
|
||||
func NewService(pg *PgStore) *Service {
|
||||
return &Service{pg: pg, now: time.Now}
|
||||
}
|
||||
|
||||
// CreateInput is what the HTTP handler hands to the service.
|
||||
type CreateInput struct {
|
||||
ID string
|
||||
Tag string
|
||||
Name string
|
||||
AvatarURL string
|
||||
}
|
||||
|
||||
// Create validates and inserts a clan.
|
||||
func (s *Service) Create(ctx context.Context, in CreateInput) (Clan, error) {
|
||||
tag := strings.ToUpper(strings.TrimSpace(in.Tag))
|
||||
if !IsValidTag(tag) {
|
||||
return Clan{}, fmt.Errorf("%w: tag must be 3 uppercase ASCII letters", ErrInvalidInput)
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
return Clan{}, fmt.Errorf("%w: name required", ErrInvalidInput)
|
||||
}
|
||||
c := Clan{
|
||||
ID: in.ID,
|
||||
Tag: tag,
|
||||
Name: name,
|
||||
AvatarURL: in.AvatarURL,
|
||||
}
|
||||
if c.ID == "" {
|
||||
c.ID = fmt.Sprintf("clan-%d-%d", s.now().UnixMilli(), time.Now().UnixNano()%1000)
|
||||
}
|
||||
if err := s.pg.Create(ctx, c); err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return Clan{}, fmt.Errorf("%w: %s", ErrAlreadyExists, tag)
|
||||
}
|
||||
return Clan{}, err
|
||||
}
|
||||
return s.pg.Get(ctx, c.ID)
|
||||
}
|
||||
|
||||
// Get loads a clan by id.
|
||||
func (s *Service) Get(ctx context.Context, id string) (Clan, error) {
|
||||
return s.pg.Get(ctx, id)
|
||||
}
|
||||
|
||||
// GetByTag loads a clan by tag.
|
||||
func (s *Service) GetByTag(ctx context.Context, tag string) (Clan, error) {
|
||||
tag = strings.ToUpper(strings.TrimSpace(tag))
|
||||
return s.pg.GetByTag(ctx, tag)
|
||||
}
|
||||
|
||||
// List returns clans with limit/offset.
|
||||
func (s *Service) List(ctx context.Context, limit, offset int) ([]Clan, error) {
|
||||
return s.pg.List(ctx, limit, offset)
|
||||
}
|
||||
|
||||
// UpdateInput is the patch payload.
|
||||
type UpdateInput struct {
|
||||
Name string
|
||||
AvatarURL string
|
||||
}
|
||||
|
||||
// Update applies a partial patch.
|
||||
func (s *Service) Update(ctx context.Context, id string, in UpdateInput) (Clan, error) {
|
||||
cur, err := s.pg.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Clan{}, err
|
||||
}
|
||||
if in.Name != "" {
|
||||
cur.Name = in.Name
|
||||
}
|
||||
if in.AvatarURL != "" {
|
||||
cur.AvatarURL = in.AvatarURL
|
||||
}
|
||||
if err := s.pg.Update(ctx, cur); err != nil {
|
||||
return Clan{}, err
|
||||
}
|
||||
return s.pg.Get(ctx, id)
|
||||
}
|
||||
|
||||
// Delete removes a clan.
|
||||
func (s *Service) Delete(ctx context.Context, id string) error {
|
||||
return s.pg.Delete(ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Package clans owns persistence and business logic for clans.
|
||||
//
|
||||
// A clan has a unique 3-letter tag (A-Z, uppercase, exactly 3 chars),
|
||||
// a name, and an optional avatar URL. Drivers reference a clan via a
|
||||
// nullable FK (see internal/drivers).
|
||||
package clans
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Errors returned by the service.
|
||||
var (
|
||||
ErrNotFound = errors.New("clan not found")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrAlreadyExists = errors.New("clan already exists")
|
||||
)
|
||||
|
||||
// tagRE enforces exactly 3 uppercase ASCII letters.
|
||||
var tagRE = regexp.MustCompile(`^[A-Z]{3}$`)
|
||||
|
||||
// IsValidTag reports whether s is a valid clan/driver tag.
|
||||
func IsValidTag(s string) bool { return tagRE.MatchString(s) }
|
||||
|
||||
// Clan is the canonical in-memory representation.
|
||||
type Clan struct {
|
||||
ID string
|
||||
Tag string
|
||||
Name string
|
||||
AvatarURL string
|
||||
CreatedMs int64
|
||||
UpdatedMs int64
|
||||
}
|
||||
|
||||
// PgStore is the Postgres-backed half of the package.
|
||||
type PgStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPgStore wraps an open pgxpool.
|
||||
func NewPgStore(pool *pgxpool.Pool) *PgStore {
|
||||
return &PgStore{pool: pool}
|
||||
}
|
||||
|
||||
// Exec exposes raw SQL for the seeder.
|
||||
func (s *PgStore) Exec(ctx context.Context, sql string) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, sql)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// Create inserts a clan.
|
||||
func (s *PgStore) Create(ctx context.Context, c Clan) error {
|
||||
now := time.Now().UnixMilli()
|
||||
if c.CreatedMs == 0 {
|
||||
c.CreatedMs = now
|
||||
}
|
||||
c.UpdatedMs = now
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO clans (id, tag, name, avatar_url, created_ms, updated_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
c.ID, c.Tag, c.Name, c.AvatarURL, c.CreatedMs, c.UpdatedMs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert clan: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get loads a clan by id.
|
||||
func (s *PgStore) Get(ctx context.Context, id string) (Clan, error) {
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`SELECT id, tag, name, avatar_url, created_ms, updated_ms
|
||||
FROM clans WHERE id = $1`, id)
|
||||
var c Clan
|
||||
if err := row.Scan(&c.ID, &c.Tag, &c.Name, &c.AvatarURL, &c.CreatedMs, &c.UpdatedMs); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Clan{}, ErrNotFound
|
||||
}
|
||||
return Clan{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// GetByTag loads a clan by its 3-letter tag.
|
||||
func (s *PgStore) GetByTag(ctx context.Context, tag string) (Clan, error) {
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`SELECT id, tag, name, avatar_url, created_ms, updated_ms
|
||||
FROM clans WHERE tag = $1`, tag)
|
||||
var c Clan
|
||||
if err := row.Scan(&c.ID, &c.Tag, &c.Name, &c.AvatarURL, &c.CreatedMs, &c.UpdatedMs); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Clan{}, ErrNotFound
|
||||
}
|
||||
return Clan{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// List returns all clans, ordered by tag.
|
||||
func (s *PgStore) List(ctx context.Context, limit, offset int) ([]Clan, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, tag, name, avatar_url, created_ms, updated_ms
|
||||
FROM clans ORDER BY tag ASC LIMIT $1 OFFSET $2`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]Clan, 0)
|
||||
for rows.Next() {
|
||||
var c Clan
|
||||
if err := rows.Scan(&c.ID, &c.Tag, &c.Name, &c.AvatarURL, &c.CreatedMs, &c.UpdatedMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Delete removes a clan by id.
|
||||
func (s *PgStore) Delete(ctx context.Context, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM clans WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update applies a partial patch.
|
||||
func (s *PgStore) Update(ctx context.Context, c Clan) error {
|
||||
c.UpdatedMs = time.Now().UnixMilli()
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE clans
|
||||
SET name = $2, avatar_url = $3, updated_ms = $4
|
||||
WHERE id = $1`,
|
||||
c.ID, c.Name, c.AvatarURL, c.UpdatedMs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count returns the total number of clans.
|
||||
func (s *PgStore) Count(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
row := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM clans`)
|
||||
if err := row.Scan(&n); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Package config loads runtime configuration from environment variables.
|
||||
//
|
||||
// On startup Load() reads a .env file (if present) from the current
|
||||
// working directory and merges its values into the process environment.
|
||||
// Existing process env vars take precedence over .env so deployments
|
||||
// can override .env without modifying the file.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Config holds all runtime parameters.
|
||||
type Config struct {
|
||||
HTTPAddr string // Address to listen on, e.g. ":8080"
|
||||
TickRate int // Race tick rate (Hz)
|
||||
TickInterval time.Duration // Derived from TickRate
|
||||
LogLevel string // "debug" | "info" | "warn" | "error"
|
||||
MaxClients int // Maximum concurrent WS clients
|
||||
SnapshotRate int // Snapshot fan-out rate (Hz)
|
||||
DevMode bool // If true, allow multiple clients without auth
|
||||
|
||||
DatabaseURL string // Postgres connection string; required at runtime
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables, applying defaults.
|
||||
//
|
||||
// A .env file in the current working directory (or any path listed in
|
||||
// X0GP_DOTENV) is loaded before reading the environment. Missing .env
|
||||
// is not an error — production deployments set vars directly.
|
||||
func Load() (*Config, error) {
|
||||
loadDotEnv()
|
||||
|
||||
c := &Config{
|
||||
HTTPAddr: getEnv("X0GP_HTTP_ADDR", ":8080"),
|
||||
TickRate: getEnvInt("X0GP_TICK_RATE", 60),
|
||||
LogLevel: getEnv("X0GP_LOG_LEVEL", "info"),
|
||||
MaxClients: getEnvInt("X0GP_MAX_CLIENTS", 100),
|
||||
SnapshotRate: getEnvInt("X0GP_SNAPSHOT_RATE", 30),
|
||||
DevMode: getEnvBool("X0GP_DEV_MODE", true),
|
||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||
}
|
||||
|
||||
if c.TickRate <= 0 || c.TickRate > 240 {
|
||||
return nil, fmt.Errorf("X0GP_TICK_RATE must be in (0, 240], got %d", c.TickRate)
|
||||
}
|
||||
c.TickInterval = time.Second / time.Duration(c.TickRate)
|
||||
|
||||
if c.SnapshotRate <= 0 || c.SnapshotRate > c.TickRate {
|
||||
return nil, fmt.Errorf("X0GP_SNAPSHOT_RATE must be in (0, %d], got %d",
|
||||
c.TickRate, c.SnapshotRate)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// loadDotEnv merges a .env file into the process environment. Files
|
||||
// that do not exist are silently ignored. Paths can be overridden with
|
||||
// X0GP_DOTENV (colon-separated). Existing process env vars take
|
||||
// precedence over .env values so deploys can override without
|
||||
// editing the file (godotenv.Load, not Overload).
|
||||
func loadDotEnv() {
|
||||
if paths := os.Getenv("X0GP_DOTENV"); paths != "" {
|
||||
for _, p := range splitPaths(paths) {
|
||||
_ = godotenv.Load(p)
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = godotenv.Load(".env")
|
||||
}
|
||||
|
||||
func splitPaths(s string) []string {
|
||||
out := make([]string, 0, 4)
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == ':' || s[i] == ';' {
|
||||
if i > start {
|
||||
out = append(out, s[start:i])
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
out = append(out, s[start:])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvBool(key string, fallback bool) bool {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// Package control owns the authoritative race state.
|
||||
//
|
||||
// For PoC, the physics model is intentionally simple (no tire slip, no
|
||||
// collision): each car integrates position from (throttle, brake, steering).
|
||||
// Realistic physics, anti-cheat checks, and CV-based ground truth will be
|
||||
// added in MVP / production phases.
|
||||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/x0gp/server/internal/transport"
|
||||
)
|
||||
|
||||
// RacePhase describes the lifecycle of a race.
|
||||
type RacePhase int
|
||||
|
||||
const (
|
||||
PhaseLobby RacePhase = 0
|
||||
PhaseCountdown RacePhase = 1
|
||||
PhaseRacing RacePhase = 2
|
||||
PhaseFinished RacePhase = 3
|
||||
)
|
||||
|
||||
func (p RacePhase) String() string {
|
||||
switch p {
|
||||
case PhaseLobby:
|
||||
return "lobby"
|
||||
case PhaseCountdown:
|
||||
return "countdown"
|
||||
case PhaseRacing:
|
||||
return "racing"
|
||||
case PhaseFinished:
|
||||
return "finished"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Car is the authoritative state of a single car.
|
||||
type Car struct {
|
||||
ID string
|
||||
DriverID string // session id
|
||||
DriverName string
|
||||
X, Y float64
|
||||
Heading float64 // radians
|
||||
Speed float64 // m/s
|
||||
Lap int
|
||||
Sector int
|
||||
LastLapMs int64
|
||||
BestLapMs int64
|
||||
DNF bool
|
||||
|
||||
// Last applied input (echoed in snapshot for client reconciliation).
|
||||
AppliedSteering float64
|
||||
AppliedThrottle float64
|
||||
AppliedBrake float64
|
||||
|
||||
mu sync.Mutex // guards input application
|
||||
}
|
||||
|
||||
// ApplyInput applies a control input with ramp limits (sanity-check).
|
||||
func (c *Car) ApplyInput(in transport.InputState, dtSec float64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Sanity clamps.
|
||||
s := clamp(in.Steering, -1, 1)
|
||||
t := clamp(in.Throttle, 0, 1)
|
||||
b := clamp(in.Brake, 0, 1)
|
||||
|
||||
// Throttle / brake -> longitudinal acceleration.
|
||||
const maxAccel = 4.0 // m/s^2
|
||||
const maxBrake = 8.0 // m/s^2
|
||||
const maxSpeed = 6.0 // m/s (1/27 scale, ~21 km/h)
|
||||
const dragK = 0.5 // linear drag coefficient
|
||||
|
||||
accel := maxAccel*t - maxBrake*b - dragK*c.Speed
|
||||
c.Speed += accel * dtSec
|
||||
if c.Speed < 0 {
|
||||
c.Speed = 0
|
||||
}
|
||||
if c.Speed > maxSpeed {
|
||||
c.Speed = maxSpeed
|
||||
}
|
||||
|
||||
// Steering -> yaw rate, proportional to speed (no turn at standstill).
|
||||
const maxYawRate = 3.5 // rad/s
|
||||
yawRate := maxYawRate * s * (0.3 + 0.7*math.Min(c.Speed/maxSpeed, 1))
|
||||
c.Heading += yawRate * dtSec
|
||||
|
||||
// Position integration.
|
||||
c.X += c.Speed * math.Cos(c.Heading) * dtSec
|
||||
c.Y += c.Speed * math.Sin(c.Heading) * dtSec
|
||||
|
||||
c.AppliedSteering = s
|
||||
c.AppliedThrottle = t
|
||||
c.AppliedBrake = b
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of car state for the wire snapshot.
|
||||
func (c *Car) Snapshot() transport.CarInfo {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
info := transport.CarInfo{
|
||||
ID: c.ID,
|
||||
DriverName: c.DriverName,
|
||||
X: c.X,
|
||||
Y: c.Y,
|
||||
Heading: c.Heading,
|
||||
Speed: c.Speed,
|
||||
Lap: c.Lap,
|
||||
Sector: c.Sector,
|
||||
LastLapMs: c.LastLapMs,
|
||||
BestLapMs: c.BestLapMs,
|
||||
DNF: c.DNF,
|
||||
}
|
||||
info.InputApplied.Steering = c.AppliedSteering
|
||||
info.InputApplied.Throttle = c.AppliedThrottle
|
||||
info.InputApplied.Brake = c.AppliedBrake
|
||||
return info
|
||||
}
|
||||
|
||||
// Engine is the authoritative race engine.
|
||||
//
|
||||
// Not safe to copy; pass by pointer.
|
||||
type Engine struct {
|
||||
mu sync.RWMutex
|
||||
phase atomic.Int32 // RacePhase
|
||||
tick atomic.Uint64
|
||||
cars map[string]*Car // keyed by Car.ID
|
||||
byDriver map[string]*Car // keyed by DriverID
|
||||
tickRate int
|
||||
dtSec float64
|
||||
|
||||
// Listeners for snapshot fan-out.
|
||||
snapCh chan transport.RaceSnapshot
|
||||
|
||||
// Lifecycle.
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewEngine creates a race engine. tickRateHz is the authoritative tick rate.
|
||||
func NewEngine(tickRateHz int) *Engine {
|
||||
return &Engine{
|
||||
phase: atomic.Int32{},
|
||||
cars: make(map[string]*Car),
|
||||
byDriver: make(map[string]*Car),
|
||||
tickRate: tickRateHz,
|
||||
dtSec: 1.0 / float64(tickRateHz),
|
||||
snapCh: make(chan transport.RaceSnapshot, 64),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshots returns the channel of authoritative race snapshots.
|
||||
func (e *Engine) Snapshots() <-chan transport.RaceSnapshot { return e.snapCh }
|
||||
|
||||
// Phase returns the current race phase.
|
||||
func (e *Engine) Phase() RacePhase { return RacePhase(e.phase.Load()) }
|
||||
|
||||
// SetPhase transitions the race phase.
|
||||
func (e *Engine) SetPhase(p RacePhase) { e.phase.Store(int32(p)) }
|
||||
|
||||
// AddCar registers a car and returns it. Returns an error if the driver
|
||||
// already has a car or the max car count is reached.
|
||||
func (e *Engine) AddCar(driverID, driverName string, slot int) (*Car, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if _, exists := e.byDriver[driverID]; exists {
|
||||
return nil, fmt.Errorf("driver %s already has a car", driverID)
|
||||
}
|
||||
if len(e.cars) >= 4 {
|
||||
return nil, fmt.Errorf("race is full (max 4 cars for PoC)")
|
||||
}
|
||||
|
||||
id := fmt.Sprintf("car-%d", slot)
|
||||
car := &Car{
|
||||
ID: id,
|
||||
DriverID: driverID,
|
||||
DriverName: driverName,
|
||||
// Spread cars along start grid (x = 0, y = slot * 0.4 m).
|
||||
Y: float64(slot) * 0.4,
|
||||
}
|
||||
e.cars[id] = car
|
||||
e.byDriver[driverID] = car
|
||||
return car, nil
|
||||
}
|
||||
|
||||
// RemoveCar removes a car by driver id.
|
||||
func (e *Engine) RemoveCar(driverID string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
car, ok := e.byDriver[driverID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
delete(e.byDriver, driverID)
|
||||
delete(e.cars, car.ID)
|
||||
}
|
||||
|
||||
// GetCarByDriver returns the car for a driver (or nil).
|
||||
func (e *Engine) GetCarByDriver(driverID string) *Car {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.byDriver[driverID]
|
||||
}
|
||||
|
||||
// Run drives the tick loop. Cancelled by ctx or Stop().
|
||||
func (e *Engine) Run(ctx context.Context) {
|
||||
period := time.Duration(float64(time.Second) / float64(e.tickRate))
|
||||
t := time.NewTicker(period)
|
||||
defer t.Stop()
|
||||
|
||||
// Snapshot cadence: 30 Hz for PoC (half tick rate is usually enough).
|
||||
const snapshotEveryTicks = 2
|
||||
var sinceSnap int
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-e.stopCh:
|
||||
return
|
||||
case <-t.C:
|
||||
e.advance()
|
||||
sinceSnap++
|
||||
if sinceSnap >= snapshotEveryTicks {
|
||||
sinceSnap = 0
|
||||
e.publishSnapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop terminates the tick loop.
|
||||
func (e *Engine) Stop() { close(e.stopCh) }
|
||||
|
||||
// advance integrates one tick of physics (no-op if no cars).
|
||||
func (e *Engine) advance() {
|
||||
e.tick.Add(1)
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
for _, car := range e.cars {
|
||||
if car.DNF {
|
||||
continue
|
||||
}
|
||||
// PoC: keep applying last input without changes.
|
||||
// In real life, the input pipeline feeds this.
|
||||
in := transport.InputState{
|
||||
Steering: car.AppliedSteering,
|
||||
Throttle: car.AppliedThrottle,
|
||||
Brake: car.AppliedBrake,
|
||||
}
|
||||
car.ApplyInput(in, e.dtSec)
|
||||
}
|
||||
}
|
||||
|
||||
// publishSnapshot sends a snapshot to subscribers (non-blocking).
|
||||
func (e *Engine) publishSnapshot() {
|
||||
e.mu.RLock()
|
||||
cars := make([]transport.CarInfo, 0, len(e.cars))
|
||||
for _, c := range e.cars {
|
||||
cars = append(cars, c.Snapshot())
|
||||
}
|
||||
e.mu.RUnlock()
|
||||
|
||||
snap := transport.RaceSnapshot{
|
||||
Tick: e.tick.Load(),
|
||||
TSMs: time.Now().UnixMilli(),
|
||||
Elapsed: float64(e.tick.Load()) * e.dtSec,
|
||||
Cars: cars,
|
||||
}
|
||||
|
||||
select {
|
||||
case e.snapCh <- snap:
|
||||
default:
|
||||
// Drop if subscriber is slow; log in prod.
|
||||
}
|
||||
}
|
||||
|
||||
func clamp(v, lo, hi float64) float64 {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// Service composes validation on top of PgStore.
|
||||
type Service struct {
|
||||
pg *PgStore
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService wires a service.
|
||||
func NewService(pg *PgStore) *Service {
|
||||
return &Service{pg: pg, now: time.Now}
|
||||
}
|
||||
|
||||
// CreateInput is what the HTTP handler hands to the service.
|
||||
type CreateInput struct {
|
||||
ID string
|
||||
Nickname string
|
||||
Name string
|
||||
AvatarURL string
|
||||
ClanID string
|
||||
}
|
||||
|
||||
// Create validates and inserts a driver.
|
||||
func (s *Service) Create(ctx context.Context, in CreateInput) (Driver, error) {
|
||||
nick := strings.ToUpper(strings.TrimSpace(in.Nickname))
|
||||
if !IsValidNickname(nick) {
|
||||
return Driver{}, fmt.Errorf("%w: nickname must be 3 uppercase ASCII letters", ErrInvalidInput)
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
return Driver{}, fmt.Errorf("%w: name required", ErrInvalidInput)
|
||||
}
|
||||
d := Driver{
|
||||
ID: in.ID,
|
||||
Nickname: nick,
|
||||
Name: name,
|
||||
AvatarURL: in.AvatarURL,
|
||||
ClanID: in.ClanID,
|
||||
}
|
||||
if d.ID == "" {
|
||||
d.ID = fmt.Sprintf("driver-%d-%d", s.now().UnixMilli(), time.Now().UnixNano()%1000)
|
||||
}
|
||||
if err := s.pg.Create(ctx, d); err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return Driver{}, fmt.Errorf("%w: %s", ErrAlreadyExists, nick)
|
||||
}
|
||||
return Driver{}, err
|
||||
}
|
||||
return s.pg.Get(ctx, d.ID)
|
||||
}
|
||||
|
||||
// Get loads a driver by id.
|
||||
func (s *Service) Get(ctx context.Context, id string) (Driver, error) {
|
||||
return s.pg.Get(ctx, id)
|
||||
}
|
||||
|
||||
// GetByNickname loads a driver by nickname.
|
||||
func (s *Service) GetByNickname(ctx context.Context, nick string) (Driver, error) {
|
||||
nick = strings.ToUpper(strings.TrimSpace(nick))
|
||||
return s.pg.GetByNickname(ctx, nick)
|
||||
}
|
||||
|
||||
// List returns drivers with limit/offset and optional clan filter.
|
||||
func (s *Service) List(ctx context.Context, limit, offset int, clanID string) ([]Driver, error) {
|
||||
return s.pg.List(ctx, limit, offset, clanID)
|
||||
}
|
||||
|
||||
// UpdateInput is the patch payload.
|
||||
type UpdateInput struct {
|
||||
Name string
|
||||
AvatarURL string
|
||||
ClanID *string // nil = leave unchanged, &"" = clear, &"..." = set
|
||||
}
|
||||
|
||||
// Update applies a partial patch.
|
||||
func (s *Service) Update(ctx context.Context, id string, in UpdateInput) (Driver, error) {
|
||||
cur, err := s.pg.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Driver{}, err
|
||||
}
|
||||
if in.Name != "" {
|
||||
cur.Name = in.Name
|
||||
}
|
||||
if in.AvatarURL != "" {
|
||||
cur.AvatarURL = in.AvatarURL
|
||||
}
|
||||
if in.ClanID != nil {
|
||||
cur.ClanID = *in.ClanID
|
||||
}
|
||||
if err := s.pg.Update(ctx, cur); err != nil {
|
||||
return Driver{}, err
|
||||
}
|
||||
return s.pg.Get(ctx, id)
|
||||
}
|
||||
|
||||
// Delete removes a driver.
|
||||
func (s *Service) Delete(ctx context.Context, id string) error {
|
||||
return s.pg.Delete(ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Package drivers owns persistence and business logic for drivers
|
||||
// (racers). Each driver has a unique 3-letter nickname (A-Z,
|
||||
// uppercase, exactly 3 chars), a name, an optional avatar URL and an
|
||||
// optional clan reference.
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Errors returned by the service.
|
||||
var (
|
||||
ErrNotFound = errors.New("driver not found")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrAlreadyExists = errors.New("driver already exists")
|
||||
)
|
||||
|
||||
// nicknameRE enforces exactly 3 uppercase ASCII letters.
|
||||
var nicknameRE = regexp.MustCompile(`^[A-Z]{3}$`)
|
||||
|
||||
// IsValidNickname reports whether s is a valid driver nickname.
|
||||
func IsValidNickname(s string) bool { return nicknameRE.MatchString(s) }
|
||||
|
||||
// Driver is the canonical in-memory representation.
|
||||
type Driver struct {
|
||||
ID string
|
||||
Nickname string
|
||||
Name string
|
||||
AvatarURL string
|
||||
ClanID string
|
||||
CreatedMs int64
|
||||
UpdatedMs int64
|
||||
}
|
||||
|
||||
// PgStore is the Postgres-backed half of the package.
|
||||
type PgStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPgStore wraps an open pgxpool.
|
||||
func NewPgStore(pool *pgxpool.Pool) *PgStore {
|
||||
return &PgStore{pool: pool}
|
||||
}
|
||||
|
||||
// Exec exposes raw SQL for the seeder.
|
||||
func (s *PgStore) Exec(ctx context.Context, sql string) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, sql)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// Create inserts a driver.
|
||||
func (s *PgStore) Create(ctx context.Context, d Driver) error {
|
||||
now := time.Now().UnixMilli()
|
||||
if d.CreatedMs == 0 {
|
||||
d.CreatedMs = now
|
||||
}
|
||||
d.UpdatedMs = now
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO drivers (id, nickname, name, avatar_url, clan_id, created_ms, updated_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
d.ID, d.Nickname, d.Name, d.AvatarURL, nullableClan(d.ClanID), d.CreatedMs, d.UpdatedMs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert driver: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullableClan(id string) any {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// Get loads a driver by id.
|
||||
func (s *PgStore) Get(ctx context.Context, id string) (Driver, error) {
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`SELECT id, nickname, name, avatar_url, COALESCE(clan_id, ''), created_ms, updated_ms
|
||||
FROM drivers WHERE id = $1`, id)
|
||||
var d Driver
|
||||
if err := row.Scan(&d.ID, &d.Nickname, &d.Name, &d.AvatarURL, &d.ClanID, &d.CreatedMs, &d.UpdatedMs); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Driver{}, ErrNotFound
|
||||
}
|
||||
return Driver{}, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// GetByNickname loads a driver by their 3-letter nickname.
|
||||
func (s *PgStore) GetByNickname(ctx context.Context, nick string) (Driver, error) {
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`SELECT id, nickname, name, avatar_url, COALESCE(clan_id, ''), created_ms, updated_ms
|
||||
FROM drivers WHERE nickname = $1`, nick)
|
||||
var d Driver
|
||||
if err := row.Scan(&d.ID, &d.Nickname, &d.Name, &d.AvatarURL, &d.ClanID, &d.CreatedMs, &d.UpdatedMs); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Driver{}, ErrNotFound
|
||||
}
|
||||
return Driver{}, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// List returns all drivers, ordered by nickname.
|
||||
func (s *PgStore) List(ctx context.Context, limit, offset int, clanID string) ([]Driver, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
q := `SELECT id, nickname, name, avatar_url, COALESCE(clan_id, ''), created_ms, updated_ms
|
||||
FROM drivers`
|
||||
args := []any{}
|
||||
if clanID != "" {
|
||||
q += ` WHERE clan_id = $1`
|
||||
args = append(args, clanID)
|
||||
args = append(args, limit, offset)
|
||||
q += ` ORDER BY nickname ASC LIMIT $2 OFFSET $3`
|
||||
} else {
|
||||
args = append(args, limit, offset)
|
||||
q += ` ORDER BY nickname ASC LIMIT $1 OFFSET $2`
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]Driver, 0)
|
||||
for rows.Next() {
|
||||
var d Driver
|
||||
if err := rows.Scan(&d.ID, &d.Nickname, &d.Name, &d.AvatarURL, &d.ClanID, &d.CreatedMs, &d.UpdatedMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Update applies a partial patch.
|
||||
func (s *PgStore) Update(ctx context.Context, d Driver) error {
|
||||
d.UpdatedMs = time.Now().UnixMilli()
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE drivers
|
||||
SET name = $2, avatar_url = $3, clan_id = $4, updated_ms = $5
|
||||
WHERE id = $1`,
|
||||
d.ID, d.Name, d.AvatarURL, nullableClan(d.ClanID), d.UpdatedMs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a driver by id.
|
||||
func (s *PgStore) Delete(ctx context.Context, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM drivers WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count returns the total number of drivers.
|
||||
func (s *PgStore) Count(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
row := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM drivers`)
|
||||
if err := row.Scan(&n); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Errors returned by the lobby service.
|
||||
var (
|
||||
ErrRaceNotFound = errors.New("race not found")
|
||||
ErrRaceFull = errors.New("race is full")
|
||||
ErrRaceNotOpen = errors.New("race is not open for joining")
|
||||
ErrRaceAlreadyExists = errors.New("race already exists")
|
||||
ErrDriverNotFound = errors.New("driver not found")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
)
|
||||
|
||||
// Options for CreateRace.
|
||||
type CreateRaceOptions struct {
|
||||
ID string // optional; auto-generated if empty
|
||||
Name string // required
|
||||
TrackID string // optional, defaults to "default"
|
||||
MaxCars int // required, 1..8
|
||||
Laps int // 0 = time-based
|
||||
TimeLimitS int // 0 = lap-based
|
||||
}
|
||||
|
||||
// DefaultMaxCars is the fallback when CreateRaceOptions doesn't specify.
|
||||
const DefaultMaxCars = 4
|
||||
|
||||
// raceCounter assigns sequential IDs when caller doesn't provide one.
|
||||
var raceCounter seqCounter
|
||||
|
||||
// CreateRace registers a new race in the lobby.
|
||||
//
|
||||
// Rules:
|
||||
// - race name is required (non-empty)
|
||||
// - max_cars must be in [1, 8]
|
||||
// - laps and time_limit_s can't both be 0
|
||||
//
|
||||
// Races are owned by a physical track; there is no host driver. Drivers
|
||||
// join via AddDriverToRace.
|
||||
func (s *Service) CreateRace(opts CreateRaceOptions) (RaceMeta, error) {
|
||||
name := strings.TrimSpace(opts.Name)
|
||||
if name == "" {
|
||||
return RaceMeta{}, fmt.Errorf("%w: name required", ErrInvalidInput)
|
||||
}
|
||||
max := opts.MaxCars
|
||||
if max == 0 {
|
||||
max = DefaultMaxCars
|
||||
}
|
||||
if max < 1 || max > 8 {
|
||||
return RaceMeta{}, fmt.Errorf("%w: max_cars must be 1..8", ErrInvalidInput)
|
||||
}
|
||||
if opts.Laps == 0 && opts.TimeLimitS == 0 {
|
||||
return RaceMeta{}, fmt.Errorf("%w: laps or time_limit_s required", ErrInvalidInput)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
id := opts.ID
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("race-%d-%d", time.Now().Unix(), raceCounter.v.Add(1))
|
||||
} else if _, exists := s.races[id]; exists {
|
||||
s.mu.Unlock()
|
||||
return RaceMeta{}, ErrRaceAlreadyExists
|
||||
}
|
||||
|
||||
track := opts.TrackID
|
||||
if track == "" {
|
||||
track = "default"
|
||||
}
|
||||
|
||||
meta := RaceMeta{
|
||||
ID: id,
|
||||
Name: name,
|
||||
TrackID: track,
|
||||
MaxCars: max,
|
||||
Laps: opts.Laps,
|
||||
TimeLimitS: opts.TimeLimitS,
|
||||
DriverIDs: []string{},
|
||||
Status: RaceStatusLobby,
|
||||
CreatedMs: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
s.races[id] = &meta
|
||||
hook := s.onRaceCreated
|
||||
s.mu.Unlock()
|
||||
|
||||
if hook != nil {
|
||||
hook(meta)
|
||||
}
|
||||
s.bump("race_created", id, "")
|
||||
s.mirror(func(ctx context.Context, p Persistence) error { return p.UpsertRace(ctx, meta) })
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// AddRace inserts a race into the in-memory lobby without triggering a
|
||||
// persistence write. Used by RestoreFromDB where the row is already
|
||||
// in Postgres. Bumps the lobby version so subscribers re-sync.
|
||||
func (s *Service) AddRace(r RaceMeta) {
|
||||
if r.ID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
r2 := r
|
||||
s.races[r.ID] = &r2
|
||||
s.mu.Unlock()
|
||||
s.bump("race_restored", r.ID, "")
|
||||
}
|
||||
|
||||
// AddDriver registers a new driver or refreshes an existing one.
|
||||
// If the driver already exists with a different name, the name is updated.
|
||||
func (s *Service) AddDriver(id, name, country string) (DriverMeta, error) {
|
||||
if id == "" {
|
||||
return DriverMeta{}, fmt.Errorf("%w: id required", ErrInvalidInput)
|
||||
}
|
||||
if name == "" {
|
||||
name = id
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
now := time.Now().UnixMilli()
|
||||
broadcastKind := ""
|
||||
if existing, ok := s.drivers[id]; ok {
|
||||
existing.LastSeenMs = now
|
||||
if name != "" {
|
||||
existing.Name = name
|
||||
}
|
||||
if country != "" {
|
||||
existing.Country = country
|
||||
}
|
||||
if existing.Status == DriverStatusOffline {
|
||||
existing.Status = DriverStatusIdle
|
||||
broadcastKind = "driver_joined"
|
||||
}
|
||||
out := *existing
|
||||
s.mu.Unlock()
|
||||
if broadcastKind != "" {
|
||||
s.bump(broadcastKind, "", id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
d := DriverMeta{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Status: DriverStatusIdle,
|
||||
ConnectedMs: now,
|
||||
LastSeenMs: now,
|
||||
Country: country,
|
||||
AvatarHue: hashHue(id),
|
||||
}
|
||||
s.drivers[id] = &d
|
||||
s.mu.Unlock()
|
||||
s.bump("driver_joined", "", id)
|
||||
s.mirror(func(ctx context.Context, p Persistence) error {
|
||||
return p.UpsertDriver(ctx, d)
|
||||
})
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// RemoveDriver marks a driver as offline. The driver is kept in memory
|
||||
// briefly to allow reconnect grace (see Snapshot filter).
|
||||
func (s *Service) RemoveDriver(id string) {
|
||||
var raceIDToRemove string
|
||||
s.mu.Lock()
|
||||
d, ok := s.drivers[id]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
// If still racing, treat as crash — remove from race.
|
||||
if d.Status == DriverStatusRacing && d.RaceID != "" {
|
||||
if race, ok := s.races[d.RaceID]; ok {
|
||||
race.DriverIDs = removeString(race.DriverIDs, id)
|
||||
if len(race.DriverIDs) == 0 {
|
||||
delete(s.races, d.RaceID)
|
||||
raceIDToRemove = d.RaceID
|
||||
}
|
||||
}
|
||||
}
|
||||
d.Status = DriverStatusOffline
|
||||
d.LastSeenMs = time.Now().UnixMilli()
|
||||
d.RaceID = ""
|
||||
hook := s.onRaceRemoved
|
||||
s.mu.Unlock()
|
||||
|
||||
if raceIDToRemove != "" && hook != nil {
|
||||
hook(raceIDToRemove)
|
||||
}
|
||||
s.bump("driver_left", "", id)
|
||||
if raceIDToRemove != "" {
|
||||
s.mirror(func(ctx context.Context, p Persistence) error {
|
||||
return p.DeleteRace(context.Background(), raceIDToRemove)
|
||||
})
|
||||
}
|
||||
s.mirror(func(ctx context.Context, p Persistence) error {
|
||||
return p.DeleteDriver(ctx, id)
|
||||
})
|
||||
}
|
||||
|
||||
// Heartbeat refreshes LastSeenMs (call periodically from session).
|
||||
func (s *Service) Heartbeat(id string) {
|
||||
s.mu.Lock()
|
||||
if d, ok := s.drivers[id]; ok {
|
||||
d.LastSeenMs = time.Now().UnixMilli()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetDriverProfile updates the denormalised display fields of a driver
|
||||
// (nickname, avatar URL, clan). Safe to call on a missing driver (no-op).
|
||||
func (s *Service) SetDriverProfile(id, nickname, avatarURL, clanID, clanTag string) {
|
||||
s.mu.Lock()
|
||||
var d *DriverMeta
|
||||
if v, ok := s.drivers[id]; ok {
|
||||
v.Nickname = nickname
|
||||
v.AvatarURL = avatarURL
|
||||
v.ClanID = clanID
|
||||
v.ClanTag = clanTag
|
||||
d = v
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if d != nil {
|
||||
s.mirror(func(ctx context.Context, p Persistence) error {
|
||||
return p.UpsertDriver(ctx, *d)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AddDriverToRace moves a driver from idle into a race. Used by the
|
||||
// client->server JoinRace flow (in addition to the race engine state).
|
||||
// Idempotent: re-joining the same race is a no-op.
|
||||
func (s *Service) AddDriverToRace(driverID, raceID string) error {
|
||||
s.mu.Lock()
|
||||
d, ok := s.drivers[driverID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return ErrDriverNotFound
|
||||
}
|
||||
r, ok := s.races[raceID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return ErrRaceNotFound
|
||||
}
|
||||
if r.Status != RaceStatusLobby {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("%w: status=%s", ErrRaceNotOpen, r.Status)
|
||||
}
|
||||
if len(r.DriverIDs) >= r.MaxCars {
|
||||
s.mu.Unlock()
|
||||
return ErrRaceFull
|
||||
}
|
||||
if containsString(r.DriverIDs, driverID) {
|
||||
d.Status = DriverStatusRacing
|
||||
d.RaceID = raceID
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
r.DriverIDs = append(r.DriverIDs, driverID)
|
||||
d.Status = DriverStatusRacing
|
||||
d.RaceID = raceID
|
||||
slot := len(r.DriverIDs) - 1
|
||||
s.mu.Unlock()
|
||||
s.bump("race_updated", raceID, driverID)
|
||||
s.mirror(func(ctx context.Context, p Persistence) error {
|
||||
return p.AddDriver(ctx, raceID, driverID, slot)
|
||||
})
|
||||
s.mirror(func(ctx context.Context, p Persistence) error {
|
||||
return p.UpsertDriver(ctx, *d)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveDriverFromRace returns a driver to the lobby.
|
||||
func (s *Service) RemoveDriverFromRace(driverID string) {
|
||||
var raceIDRemoved string
|
||||
s.mu.Lock()
|
||||
d, ok := s.drivers[driverID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if d.RaceID == "" {
|
||||
d.Status = DriverStatusIdle
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r, ok := s.races[d.RaceID]
|
||||
if ok {
|
||||
r.DriverIDs = removeString(r.DriverIDs, driverID)
|
||||
if len(r.DriverIDs) == 0 {
|
||||
delete(s.races, d.RaceID)
|
||||
raceIDRemoved = d.RaceID
|
||||
}
|
||||
}
|
||||
d.Status = DriverStatusIdle
|
||||
d.RaceID = ""
|
||||
prevRaceID := d.RaceID
|
||||
hook := s.onRaceRemoved
|
||||
s.mu.Unlock()
|
||||
|
||||
if raceIDRemoved != "" && hook != nil {
|
||||
hook(raceIDRemoved)
|
||||
}
|
||||
s.bump("race_updated", prevRaceID, driverID)
|
||||
if prevRaceID != "" {
|
||||
s.mirror(func(ctx context.Context, p Persistence) error {
|
||||
return p.RemoveDriver(ctx, prevRaceID, driverID)
|
||||
})
|
||||
}
|
||||
s.mirror(func(ctx context.Context, p Persistence) error {
|
||||
return p.UpsertDriver(ctx, *d)
|
||||
})
|
||||
if raceIDRemoved != "" {
|
||||
s.mirror(func(ctx context.Context, p Persistence) error {
|
||||
return p.DeleteRace(ctx, raceIDRemoved)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SetRaceStatus updates the status (used by Engine when race starts/ends).
|
||||
func (s *Service) SetRaceStatus(raceID string, status RaceStatus) error {
|
||||
s.mu.Lock()
|
||||
r, ok := s.races[raceID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return ErrRaceNotFound
|
||||
}
|
||||
r.Status = status
|
||||
if status == RaceStatusRacing && r.StartedMs == 0 {
|
||||
r.StartedMs = time.Now().UnixMilli()
|
||||
}
|
||||
startedMs := r.StartedMs
|
||||
var affected []DriverMeta
|
||||
if status == RaceStatusFinished {
|
||||
for _, did := range r.DriverIDs {
|
||||
if d, ok := s.drivers[did]; ok {
|
||||
d.Status = DriverStatusIdle
|
||||
d.RaceID = ""
|
||||
affected = append(affected, *d)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.bump("race_updated", raceID, "")
|
||||
s.mirror(func(ctx context.Context, p Persistence) error {
|
||||
return p.SetRaceStatus(ctx, raceID, status, startedMs)
|
||||
})
|
||||
for i := range affected {
|
||||
d := affected[i]
|
||||
s.mirror(func(ctx context.Context, p Persistence) error {
|
||||
return p.UpsertDriver(ctx, d)
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRace removes a race entirely. Drivers inside are returned to idle.
|
||||
func (s *Service) DeleteRace(raceID string) error {
|
||||
s.mu.Lock()
|
||||
r, ok := s.races[raceID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return ErrRaceNotFound
|
||||
}
|
||||
for _, did := range r.DriverIDs {
|
||||
if d, ok := s.drivers[did]; ok {
|
||||
d.Status = DriverStatusIdle
|
||||
d.RaceID = ""
|
||||
}
|
||||
}
|
||||
delete(s.races, raceID)
|
||||
hook := s.onRaceRemoved
|
||||
s.mu.Unlock()
|
||||
if hook != nil {
|
||||
hook(raceID)
|
||||
}
|
||||
s.bump("race_removed", raceID, "")
|
||||
s.mirror(func(ctx context.Context, p Persistence) error {
|
||||
return p.DeleteRace(ctx, raceID)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// QuickPlay assigns a driver to the first available race, or creates
|
||||
// a new one if none exist. Returns the chosen race.
|
||||
func (s *Service) QuickPlay(driverID string, defaultMaxCars int) (RaceMeta, error) {
|
||||
var candidateID string
|
||||
s.mu.RLock()
|
||||
for _, r := range s.races {
|
||||
if r.Status == RaceStatusLobby && len(r.DriverIDs) < r.MaxCars {
|
||||
candidateID = r.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
if candidateID != "" {
|
||||
if err := s.AddDriverToRace(driverID, candidateID); err != nil {
|
||||
return RaceMeta{}, err
|
||||
}
|
||||
return s.GetRace(candidateID)
|
||||
}
|
||||
driver, err := s.GetDriver(driverID)
|
||||
if err != nil {
|
||||
return RaceMeta{}, err
|
||||
}
|
||||
race, err := s.CreateRace(CreateRaceOptions{
|
||||
Name: driver.Name + "'s race",
|
||||
MaxCars: defaultMaxCars,
|
||||
Laps: 5,
|
||||
})
|
||||
if err != nil {
|
||||
return RaceMeta{}, err
|
||||
}
|
||||
if err := s.AddDriverToRace(driverID, race.ID); err != nil {
|
||||
return RaceMeta{}, err
|
||||
}
|
||||
return s.GetRace(race.ID)
|
||||
}
|
||||
|
||||
// GetRace returns the current meta for a race (by value, safe to mutate).
|
||||
func (s *Service) GetRace(raceID string) (RaceMeta, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
r, ok := s.races[raceID]
|
||||
if !ok {
|
||||
return RaceMeta{}, ErrRaceNotFound
|
||||
}
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
// GetDriver returns the current meta for a driver.
|
||||
func (s *Service) GetDriver(driverID string) (DriverMeta, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
d, ok := s.drivers[driverID]
|
||||
if !ok {
|
||||
return DriverMeta{}, ErrDriverNotFound
|
||||
}
|
||||
return *d, nil
|
||||
}
|
||||
|
||||
// ListRaces returns a snapshot copy of all races.
|
||||
func (s *Service) ListRaces() []RaceMeta {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]RaceMeta, 0, len(s.races))
|
||||
for _, r := range s.races {
|
||||
out = append(out, *r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListDrivers returns a snapshot copy of all non-offline drivers.
|
||||
func (s *Service) ListDrivers() []DriverMeta {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]DriverMeta, 0, len(s.drivers))
|
||||
for _, d := range s.drivers {
|
||||
if d.Status != DriverStatusOffline {
|
||||
out = append(out, *d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// helpers -----------------------------------------------------------------
|
||||
|
||||
func removeString(s []string, v string) []string {
|
||||
for i, x := range s {
|
||||
if x == v {
|
||||
return append(s[:i], s[i+1:]...)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func containsString(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hashHue returns a stable 0..360 hue for the given string.
|
||||
func hashHue(s string) int {
|
||||
var h uint32 = 5381
|
||||
for i := 0; i < len(s); i++ {
|
||||
h = h*33 ^ uint32(s[i])
|
||||
}
|
||||
return int(h % 360)
|
||||
}
|
||||
|
||||
type seqCounter struct{ v atomic.Uint64 }
|
||||
@@ -0,0 +1,120 @@
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAddDriver(t *testing.T) {
|
||||
s := NewService()
|
||||
d, err := s.AddDriver("d1", "Alice", "RU")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDriver: %v", err)
|
||||
}
|
||||
if d.Status != DriverStatusIdle {
|
||||
t.Errorf("expected idle, got %s", d.Status)
|
||||
}
|
||||
if d.Name != "Alice" {
|
||||
t.Errorf("name: got %s", d.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRaceValidates(t *testing.T) {
|
||||
s := NewService()
|
||||
_, _ = s.AddDriver("d1", "Alice", "")
|
||||
tests := []struct {
|
||||
name string
|
||||
opts CreateRaceOptions
|
||||
want string // substring of error message or "" for OK
|
||||
}{
|
||||
{"empty name", CreateRaceOptions{Name: "", MaxCars: 2, Laps: 1}, "name required"},
|
||||
{"max cars too low", CreateRaceOptions{Name: "r", MaxCars: 0, Laps: 1}, ""}, // defaults
|
||||
{"max cars too high", CreateRaceOptions{Name: "r", MaxCars: 9, Laps: 1}, "max_cars must be"},
|
||||
{"no laps and no time", CreateRaceOptions{Name: "r", MaxCars: 2}, "laps or time_limit_s"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := s.CreateRace(tt.opts)
|
||||
if tt.want == "" && err != nil {
|
||||
t.Fatalf("expected ok, got %v", err)
|
||||
}
|
||||
if tt.want != "" && err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.want)
|
||||
}
|
||||
if tt.want != "" && err != nil && !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error %q does not contain %q", err.Error(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRaceEmpty(t *testing.T) {
|
||||
s := NewService()
|
||||
r, err := s.CreateRace(CreateRaceOptions{
|
||||
Name: "open",
|
||||
TrackID: "default",
|
||||
MaxCars: 4,
|
||||
Laps: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRace: %v", err)
|
||||
}
|
||||
if r.Status != RaceStatusLobby {
|
||||
t.Errorf("status: got %s", r.Status)
|
||||
}
|
||||
if len(r.DriverIDs) != 0 {
|
||||
t.Errorf("expected empty drivers, got %d", len(r.DriverIDs))
|
||||
}
|
||||
if r.TrackID != "default" {
|
||||
t.Errorf("track: got %s", r.TrackID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickPlayJoinsFirstRace(t *testing.T) {
|
||||
s := NewService()
|
||||
_, _ = s.AddDriver("d1", "Alice", "")
|
||||
_, _ = s.AddDriver("d2", "Bob", "")
|
||||
r1, _ := s.QuickPlay("d1", 4)
|
||||
r2, err := s.QuickPlay("d2", 4)
|
||||
if err != nil {
|
||||
t.Fatalf("QuickPlay d2: %v", err)
|
||||
}
|
||||
if r2.ID != r1.ID {
|
||||
t.Errorf("expected to join %s, got %s", r1.ID, r2.ID)
|
||||
}
|
||||
if len(r2.DriverIDs) != 2 {
|
||||
t.Errorf("expected 2 drivers, got %d", len(r2.DriverIDs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeReceivesSnapshot(t *testing.T) {
|
||||
s := NewService()
|
||||
ch, cancel := s.Subscribe()
|
||||
defer cancel()
|
||||
// Subscribe pushes initial snapshot.
|
||||
select {
|
||||
case ev := <-ch:
|
||||
if ev.kind != "snapshot" {
|
||||
t.Fatalf("first event: kind=%s", ev.kind)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("no initial snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeReceivesRaceCreated(t *testing.T) {
|
||||
s := NewService()
|
||||
ch, cancel := s.Subscribe()
|
||||
defer cancel()
|
||||
<-ch // drain initial snapshot
|
||||
_, _ = s.AddDriver("d1", "Alice", "")
|
||||
select {
|
||||
case ev := <-ch:
|
||||
if ev.kind != "driver_joined" {
|
||||
t.Fatalf("expected driver_joined, got %s", ev.kind)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("no event after AddDriver")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
// Package lobby owns the "outside any race" state of the server: which
|
||||
// races are open, which drivers are connected but idle, who is the host
|
||||
// of each race.
|
||||
//
|
||||
// The lobby is the single source of truth for matchmaking and for
|
||||
// rendering the "what races are available right now" list. It does NOT
|
||||
// own per-race physics — that's the Engine (internal/control).
|
||||
//
|
||||
// All operations are thread-safe. Mutations broadcast a snapshot event
|
||||
// to subscribers (see Service.Subscribe).
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RaceStatus is the lifecycle of a race.
|
||||
type RaceStatus string
|
||||
|
||||
const (
|
||||
RaceStatusLobby RaceStatus = "lobby" // waiting for drivers, no countdown yet
|
||||
RaceStatusCountdown RaceStatus = "countdown" // countdown in progress
|
||||
RaceStatusRacing RaceStatus = "racing" // live
|
||||
RaceStatusFinished RaceStatus = "finished" // results recorded, race closes soon
|
||||
)
|
||||
|
||||
// RaceMeta describes a single race as it appears in the lobby.
|
||||
type RaceMeta struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TrackID string `json:"track_id"` // logical track identifier ("oval-a", "figure-8", ...)
|
||||
MaxCars int `json:"max_cars"` // capacity (PoC: 1..4)
|
||||
Laps int `json:"laps"` // total laps (0 = time-based)
|
||||
TimeLimitS int `json:"time_limit_s"` // seconds; 0 = lap-based only
|
||||
DriverIDs []string `json:"driver_ids"` // joined drivers
|
||||
Status RaceStatus `json:"status"`
|
||||
CreatedMs int64 `json:"created_ms"`
|
||||
StartedMs int64 `json:"started_ms,omitempty"` // 0 if not started yet
|
||||
}
|
||||
|
||||
// DriverStatus describes a driver as seen by the lobby.
|
||||
type DriverStatus string
|
||||
|
||||
const (
|
||||
DriverStatusIdle DriverStatus = "idle" // connected, not in any race
|
||||
DriverStatusRacing DriverStatus = "racing" // in a race
|
||||
DriverStatusOffline DriverStatus = "offline" // disconnected (kept briefly for reconnect grace)
|
||||
)
|
||||
|
||||
// DriverMeta describes a driver in the lobby.
|
||||
type DriverMeta struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Nickname string `json:"nickname,omitempty"` // 3-letter unique tag (A-Z)
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
ClanID string `json:"clan_id,omitempty"`
|
||||
ClanTag string `json:"clan_tag,omitempty"` // denormalised for UI
|
||||
Status DriverStatus `json:"status"`
|
||||
RaceID string `json:"race_id,omitempty"` // current race if Status=racing
|
||||
ConnectedMs int64 `json:"connected_ms"`
|
||||
LastSeenMs int64 `json:"last_seen_ms"`
|
||||
Country string `json:"country,omitempty"` // optional, for UI
|
||||
AvatarHue int `json:"avatar_hue,omitempty"` // 0..360, deterministic per id
|
||||
}
|
||||
|
||||
// Snapshot is the immutable view broadcast to clients.
|
||||
type Snapshot struct {
|
||||
GeneratedMs int64 `json:"generated_ms"`
|
||||
Races []RaceMeta `json:"races"`
|
||||
Drivers []DriverMeta `json:"drivers"`
|
||||
Version uint64 `json:"version"` // monotonic, lets clients skip duplicates
|
||||
}
|
||||
|
||||
// Stats reports lightweight counters for /stats or /lobby summary.
|
||||
type Stats struct {
|
||||
RacesTotal int `json:"races_total"`
|
||||
RacesOpen int `json:"races_open"` // status=lobby|countdown
|
||||
RacesActive int `json:"races_active"` // status=racing
|
||||
DriversIdle int `json:"drivers_idle"`
|
||||
DriversTotal int `json:"drivers_total"`
|
||||
}
|
||||
|
||||
// DriverID is a type alias to avoid confusion with raw strings.
|
||||
type DriverID = string
|
||||
// RaceID is a type alias to avoid confusion with raw strings.
|
||||
type RaceID = string
|
||||
|
||||
// event is an internal broadcast (not exported on the wire).
|
||||
type event struct {
|
||||
kind string // "snapshot" | "race_created" | "race_removed" | "race_updated" | "driver_joined" | "driver_left"
|
||||
snapshot Snapshot
|
||||
raceID string
|
||||
driverID string
|
||||
}
|
||||
|
||||
// Service owns lobby state. Safe for concurrent use.
|
||||
type Service struct {
|
||||
mu sync.RWMutex
|
||||
races map[RaceID]*RaceMeta
|
||||
drivers map[DriverID]*DriverMeta
|
||||
version atomic.Uint64
|
||||
subsMu sync.RWMutex
|
||||
subs map[chan event]struct{}
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
|
||||
// Hooks (set by main, called by lobby on lifecycle events).
|
||||
// onRaceCreated is fired when a new race is registered in the lobby.
|
||||
onRaceCreated func(RaceMeta)
|
||||
// onRaceRemoved is fired when a race leaves the lobby (started, finished, deleted).
|
||||
onRaceRemoved func(RaceID)
|
||||
|
||||
// Optional persistence sink for live races and lobby drivers. If
|
||||
// non-nil, mutations are mirrored best-effort. Set via
|
||||
// SetPersistence.
|
||||
persist Persistence
|
||||
}
|
||||
|
||||
// Persistence is the contract the lobby uses to mirror its in-memory
|
||||
// state to durable storage. Implementations must be safe for concurrent
|
||||
// use. The lobby never blocks on a persistence failure.
|
||||
type Persistence interface {
|
||||
UpsertRace(ctx context.Context, r RaceMeta) error
|
||||
DeleteRace(ctx context.Context, raceID string) error
|
||||
AddDriver(ctx context.Context, raceID, driverID string, slot int) error
|
||||
RemoveDriver(ctx context.Context, raceID, driverID string) error
|
||||
SetRaceStatus(ctx context.Context, raceID string, status RaceStatus, startedMs int64) error
|
||||
UpsertDriver(ctx context.Context, d DriverMeta) error
|
||||
DeleteDriver(ctx context.Context, driverID string) error
|
||||
}
|
||||
|
||||
// NewService creates a fresh lobby service.
|
||||
func NewService() *Service {
|
||||
return &Service{
|
||||
races: make(map[RaceID]*RaceMeta),
|
||||
drivers: make(map[DriverID]*DriverMeta),
|
||||
subs: make(map[chan event]struct{}),
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetPersistence installs a write-through persistence sink. Pass nil to
|
||||
// disable. After this call, mutations are mirrored to p in a detached
|
||||
// goroutine; failures are silently dropped.
|
||||
func (s *Service) SetPersistence(p Persistence) {
|
||||
s.mu.Lock()
|
||||
s.persist = p
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetRaceLifecycleHook installs optional callbacks (use nil to clear).
|
||||
func (s *Service) SetRaceLifecycleHook(onCreated func(RaceMeta), onRemoved func(RaceID)) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.onRaceCreated = onCreated
|
||||
s.onRaceRemoved = onRemoved
|
||||
}
|
||||
|
||||
// Subscribe returns a channel that receives events. The channel is
|
||||
// closed when the service stops or the caller invokes the returned
|
||||
// cancel func.
|
||||
func (s *Service) Subscribe() (<-chan event, func()) {
|
||||
ch := make(chan event, 16)
|
||||
s.subsMu.Lock()
|
||||
s.subs[ch] = struct{}{}
|
||||
s.subsMu.Unlock()
|
||||
|
||||
// Push initial snapshot immediately so the subscriber doesn't have to
|
||||
// request one explicitly.
|
||||
ch <- event{kind: "snapshot", 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 builds an immutable Snapshot of current state.
|
||||
func (s *Service) snapshot() Snapshot {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := Snapshot{
|
||||
GeneratedMs: time.Now().UnixMilli(),
|
||||
Races: make([]RaceMeta, 0, len(s.races)),
|
||||
Drivers: make([]DriverMeta, 0, len(s.drivers)),
|
||||
Version: s.version.Load(),
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
for _, r := range s.races {
|
||||
out.Races = append(out.Races, *r)
|
||||
}
|
||||
for _, d := range s.drivers {
|
||||
// 30-second grace for reconnect.
|
||||
if d.Status == DriverStatusOffline && now-d.LastSeenMs > 30_000 {
|
||||
continue
|
||||
}
|
||||
out.Drivers = append(out.Drivers, *d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Snapshot returns the current state for HTTP/WS responses.
|
||||
func (s *Service) Snapshot() Snapshot { return s.snapshot() }
|
||||
|
||||
// Stats returns lightweight counters.
|
||||
func (s *Service) Stats() Stats {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := Stats{
|
||||
RacesTotal: len(s.races),
|
||||
}
|
||||
for _, r := range s.races {
|
||||
switch r.Status {
|
||||
case RaceStatusLobby, RaceStatusCountdown:
|
||||
out.RacesOpen++
|
||||
case RaceStatusRacing:
|
||||
out.RacesActive++
|
||||
}
|
||||
}
|
||||
for _, d := range s.drivers {
|
||||
if d.Status == DriverStatusOffline {
|
||||
continue
|
||||
}
|
||||
out.DriversTotal++
|
||||
if d.Status == DriverStatusIdle {
|
||||
out.DriversIdle++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bump increments the version and notifies subscribers.
|
||||
func (s *Service) bump(kind string, raceID, driverID string) {
|
||||
s.version.Add(1)
|
||||
snap := s.snapshot()
|
||||
ev := event{kind: kind, snapshot: snap, raceID: raceID, driverID: driverID}
|
||||
s.subsMu.RLock()
|
||||
defer s.subsMu.RUnlock()
|
||||
for ch := range s.subs {
|
||||
select {
|
||||
case ch <- ev:
|
||||
default:
|
||||
// Slow subscriber — drop. They'll re-sync via the next snapshot.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mirror dispatches a write-through persistence call if a sink is
|
||||
// installed. Failures are logged but never block the caller. Always
|
||||
// runs in a detached goroutine.
|
||||
func (s *Service) mirror(fn func(ctx context.Context, p Persistence) error) {
|
||||
s.mu.RLock()
|
||||
p := s.persist
|
||||
s.mu.RUnlock()
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := fn(ctx, p); err != nil {
|
||||
slog.Warn("lobby persist failed", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package races
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Cursor is a keyset cursor: opaque to the client, encoded as
|
||||
// base64url("<ms>:<id>") by Encode. Order: descending by ms, then by id.
|
||||
//
|
||||
// Empty cursor = "start from the newest".
|
||||
type Cursor struct {
|
||||
Ms int64
|
||||
ID string
|
||||
}
|
||||
|
||||
// String returns a human-readable representation (for debug logs).
|
||||
func (c Cursor) String() string {
|
||||
return fmt.Sprintf("%d:%s", c.Ms, c.ID)
|
||||
}
|
||||
|
||||
// IsZero reports whether the cursor is the zero value.
|
||||
func (c Cursor) IsZero() bool {
|
||||
return c.Ms == 0 && c.ID == ""
|
||||
}
|
||||
|
||||
// Encode renders the cursor for an HTTP query string.
|
||||
func (c Cursor) Encode() string {
|
||||
if c.IsZero() {
|
||||
return ""
|
||||
}
|
||||
raw := c.String()
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(raw))
|
||||
}
|
||||
|
||||
// DecodeCursor parses a cursor produced by Cursor.Encode.
|
||||
// Returns the zero cursor on empty input.
|
||||
func DecodeCursor(s string) (Cursor, error) {
|
||||
if s == "" {
|
||||
return Cursor{}, nil
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return Cursor{}, fmt.Errorf("%w: bad cursor encoding: %v", ErrInvalidInput, err)
|
||||
}
|
||||
parts := strings.SplitN(string(raw), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return Cursor{}, fmt.Errorf("%w: cursor must be <ms>:<id>", ErrInvalidInput)
|
||||
}
|
||||
ms, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return Cursor{}, fmt.Errorf("%w: cursor ms: %v", ErrInvalidInput, err)
|
||||
}
|
||||
return Cursor{Ms: ms, ID: parts[1]}, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package races
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/x0gp/server/internal/lobby"
|
||||
)
|
||||
|
||||
// LiveStore is a thin facade over PgStore that exposes only the live
|
||||
// race persistence methods. It implements the lobby.Persistence
|
||||
// contract so the lobby can write through it.
|
||||
//
|
||||
// Methods on this type are kept separate from the rest of PgStore
|
||||
// purely for clarity: the lobby-facing surface is small and
|
||||
// well-defined. All actual work is delegated to PgStore.
|
||||
type LiveStore struct {
|
||||
pg *PgStore
|
||||
}
|
||||
|
||||
// NewLiveStore wraps an existing PgStore.
|
||||
func NewLiveStore(pg *PgStore) *LiveStore { return &LiveStore{pg: pg} }
|
||||
|
||||
func (s *LiveStore) UpsertRace(ctx context.Context, r lobby.RaceMeta) error {
|
||||
return s.pg.UpsertLive(ctx, r)
|
||||
}
|
||||
|
||||
func (s *LiveStore) DeleteRace(ctx context.Context, raceID string) error {
|
||||
return s.pg.DeleteLive(ctx, raceID)
|
||||
}
|
||||
|
||||
func (s *LiveStore) AddDriver(ctx context.Context, raceID, driverID string, slot int) error {
|
||||
return s.pg.AddLiveDriver(ctx, raceID, driverID, slot)
|
||||
}
|
||||
|
||||
func (s *LiveStore) RemoveDriver(ctx context.Context, raceID, driverID string) error {
|
||||
return s.pg.RemoveLiveDriver(ctx, raceID, driverID)
|
||||
}
|
||||
|
||||
func (s *LiveStore) SetRaceStatus(ctx context.Context, raceID string, status lobby.RaceStatus, startedMs int64) error {
|
||||
return s.pg.SetLiveStatus(ctx, raceID, status, startedMs)
|
||||
}
|
||||
|
||||
func (s *LiveStore) UpsertDriver(ctx context.Context, d lobby.DriverMeta) error {
|
||||
return s.pg.UpsertLobbyDriver(ctx, d)
|
||||
}
|
||||
|
||||
func (s *LiveStore) DeleteDriver(ctx context.Context, driverID string) error {
|
||||
return s.pg.DeleteLobbyDriver(ctx, driverID)
|
||||
}
|
||||
|
||||
// ListAllRaces returns every live row in the unified races table.
|
||||
// Used by RestoreFromDB to rehydrate the in-memory lobby.
|
||||
func (s *LiveStore) ListAllRaces(ctx context.Context) ([]lobby.RaceMeta, error) {
|
||||
return s.pg.ListAllRaces(ctx)
|
||||
}
|
||||
|
||||
// ListAllDrivers returns all lobby drivers from the lobby_drivers table.
|
||||
func (s *LiveStore) ListAllDrivers(ctx context.Context) ([]lobby.DriverMeta, error) {
|
||||
return s.pg.ListLobbyDrivers(ctx)
|
||||
}
|
||||
|
||||
// ListLivePaged exposes a keyset page of live races.
|
||||
func (s *LiveStore) ListLivePaged(ctx context.Context, statuses []string, trackID string, cur Cursor, limit int) ([]lobby.RaceMeta, error) {
|
||||
return s.pg.ListLivePaged(ctx, statuses, trackID, cur, limit)
|
||||
}
|
||||
|
||||
// ListUpcoming returns the next N open live races.
|
||||
func (s *LiveStore) ListUpcoming(ctx context.Context, limit int) ([]lobby.RaceMeta, error) {
|
||||
return s.pg.ListLiveUpcoming(ctx, limit)
|
||||
}
|
||||
|
||||
// Exec exposes raw SQL for the seeder.
|
||||
func (s *LiveStore) Exec(ctx context.Context, sql string) (int64, error) {
|
||||
return s.pg.Exec(ctx, sql)
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
package seed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/x0gp/server/internal/clans"
|
||||
"github.com/x0gp/server/internal/drivers"
|
||||
"github.com/x0gp/server/internal/lobby"
|
||||
"github.com/x0gp/server/internal/races"
|
||||
)
|
||||
|
||||
// DefaultCounts are the volumes produced by Run.
|
||||
type DefaultCounts struct {
|
||||
Finished int // default 30
|
||||
Live int // default 5
|
||||
Plans int // default 5
|
||||
}
|
||||
|
||||
// DefaultSeed is the deterministic seed.
|
||||
const DefaultSeed = 0xA1B2C3D4
|
||||
|
||||
// DefaultNow is overridable for tests.
|
||||
var DefaultNow = time.Now
|
||||
|
||||
// Track catalogue used by the seeder. Loaded from the tracks table at
|
||||
// run time. If no tracks exist, the seeder falls back to ["default"].
|
||||
var fallbackTracks = []string{"default"}
|
||||
|
||||
// Default driver nicknames used when the drivers table is empty. Three
|
||||
// uppercase letters each (per the drivers table constraint).
|
||||
var defaultDriverSeeds = []struct {
|
||||
nick string
|
||||
name string
|
||||
}{
|
||||
{"ACE", "Alice"},
|
||||
{"BOB", "Bob"},
|
||||
{"CAR", "Carol"},
|
||||
{"DAV", "Dave"},
|
||||
{"EVE", "Eve"},
|
||||
{"FRA", "Frank"},
|
||||
{"GRA", "Grace"},
|
||||
{"HEI", "Heidi"},
|
||||
}
|
||||
|
||||
// Default clans.
|
||||
var defaultClanSeeds = []struct {
|
||||
tag string
|
||||
name string
|
||||
}{
|
||||
{"ACE", "Ace Racing"},
|
||||
{"RND", "Rounders"},
|
||||
{"SPD", "Speed Demons"},
|
||||
}
|
||||
|
||||
// Options configures Run.
|
||||
type Options struct {
|
||||
Counts DefaultCounts
|
||||
Seed int64
|
||||
Reset bool
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// Summary is the result of Run.
|
||||
type Summary struct {
|
||||
FinishedInserted int
|
||||
LiveCreated int
|
||||
PlansInserted int
|
||||
QueueInserted int
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// Runner is a self-contained seeder wired to the package's pg store and
|
||||
// lobby. Hold one per process; safe to call Run multiple times.
|
||||
type Runner struct {
|
||||
pg *races.PgStore
|
||||
lb *lobby.Service
|
||||
live *races.LiveStore
|
||||
clansPg *clans.PgStore
|
||||
driversPg *drivers.PgStore
|
||||
opt Options
|
||||
rng *rand.Rand
|
||||
now time.Time
|
||||
|
||||
// Filled by loadInputs.
|
||||
trackIDs []string
|
||||
driverIDs []string // driver ids in the order they exist after seed
|
||||
driverInfo []seedDriver // id, nick, name, clanID, clanTag
|
||||
clanByID map[string]string // clan id -> tag (for fast profile updates)
|
||||
}
|
||||
|
||||
type seedDriver struct {
|
||||
ID string
|
||||
Nickname string
|
||||
Name string
|
||||
ClanID string
|
||||
ClanTag string
|
||||
}
|
||||
|
||||
// NewRunner constructs a Runner.
|
||||
func NewRunner(pg *races.PgStore, lb *lobby.Service, live *races.LiveStore, clansPg *clans.PgStore, driversPg *drivers.PgStore) *Runner {
|
||||
return &Runner{pg: pg, lb: lb, live: live, clansPg: clansPg, driversPg: driversPg}
|
||||
}
|
||||
|
||||
// Run executes the seed against the supplied services.
|
||||
//
|
||||
// Order of operations:
|
||||
//
|
||||
// 1. (optional) reset: TRUNCATE finished_races, race_plans, race_queue;
|
||||
// remove all live races from the lobby; remove the auto-seeded drivers
|
||||
// from the lobby.
|
||||
// 2. Insert N finished races into Postgres. These do NOT touch the lobby.
|
||||
// 3. Create M live races in the lobby (mix of statuses).
|
||||
// 4. Insert K race plans into Postgres. The scheduler will materialise
|
||||
// them on its next tick (start_at_ms is in the future).
|
||||
// 5. Enqueue the first two drivers for the next 2 live races.
|
||||
func (r *Runner) Run(ctx context.Context, opt Options) (Summary, error) {
|
||||
if opt.Seed == 0 {
|
||||
opt.Seed = DefaultSeed
|
||||
}
|
||||
if opt.Now.IsZero() {
|
||||
opt.Now = DefaultNow()
|
||||
}
|
||||
if opt.Counts.Finished == 0 {
|
||||
opt.Counts.Finished = 30
|
||||
}
|
||||
if opt.Counts.Live == 0 {
|
||||
opt.Counts.Live = 5
|
||||
}
|
||||
if opt.Counts.Plans == 0 {
|
||||
opt.Counts.Plans = 5
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
var s Summary
|
||||
|
||||
if opt.Reset {
|
||||
if err := r.resetAll(ctx); err != nil {
|
||||
return s, fmt.Errorf("reset: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
r.rng = rand.New(rand.NewSource(opt.Seed))
|
||||
r.now = opt.Now
|
||||
|
||||
// 0. Seed drivers and clans (idempotent). Then load real track ids
|
||||
// from the catalog so race_plans / finished_races only reference
|
||||
// tracks that actually exist in the DB.
|
||||
if err := r.seedDriversAndClans(ctx); err != nil {
|
||||
return s, fmt.Errorf("seed drivers/clans: %w", err)
|
||||
}
|
||||
if err := r.loadTracks(ctx); err != nil {
|
||||
return s, fmt.Errorf("load tracks: %w", err)
|
||||
}
|
||||
if err := r.loadDrivers(ctx); err != nil {
|
||||
return s, fmt.Errorf("load drivers: %w", err)
|
||||
}
|
||||
|
||||
// 1. Finished races.
|
||||
for i := 0; i < opt.Counts.Finished; i++ {
|
||||
row := r.makeFinished(i)
|
||||
if err := r.pg.InsertFinished(ctx, row); err != nil {
|
||||
return s, fmt.Errorf("insert finished %d: %w", i, err)
|
||||
}
|
||||
s.FinishedInserted++
|
||||
}
|
||||
|
||||
// 2. Live races in the lobby.
|
||||
statuses := []lobby.RaceStatus{
|
||||
lobby.RaceStatusLobby,
|
||||
lobby.RaceStatusLobby,
|
||||
lobby.RaceStatusCountdown,
|
||||
lobby.RaceStatusRacing,
|
||||
lobby.RaceStatusLobby,
|
||||
}
|
||||
for i := 0; i < opt.Counts.Live && i < len(statuses); i++ {
|
||||
meta, err := r.createLive(statuses[i], i)
|
||||
if err != nil {
|
||||
return s, fmt.Errorf("create live %d: %w", i, err)
|
||||
}
|
||||
if statuses[i] != lobby.RaceStatusLobby {
|
||||
if err := r.lb.SetRaceStatus(meta.ID, statuses[i]); err != nil {
|
||||
return s, fmt.Errorf("set status %s: %w", meta.ID, err)
|
||||
}
|
||||
// Flush the new status synchronously so the DB row is in
|
||||
// sync with in-memory (mirror goroutines may not have run
|
||||
// yet).
|
||||
if r.live != nil {
|
||||
flushCtx, flushCancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
cur, err := r.lb.GetRace(meta.ID)
|
||||
if err == nil {
|
||||
_ = r.live.SetRaceStatus(flushCtx, cur.ID, cur.Status, cur.StartedMs)
|
||||
}
|
||||
flushCancel()
|
||||
}
|
||||
}
|
||||
s.LiveCreated++
|
||||
|
||||
// First two live races: enqueue first two drivers on this race.
|
||||
if i < 2 {
|
||||
for _, d := range r.driverInfo[:2] {
|
||||
if _, err := r.pg.Enqueue(ctx, d.ID, meta.ID, ""); err != nil {
|
||||
return s, fmt.Errorf("enqueue %s: %w", d.ID, err)
|
||||
}
|
||||
s.QueueInserted++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Race plans. Mix of one-shot and recurring.
|
||||
planShapes := []planShape{
|
||||
{intervalS: 0, offsetS: 5 * 60}, // one-shot in 5 min
|
||||
{intervalS: 60, offsetS: 30 * 60}, // every minute, start in 30 min
|
||||
{intervalS: 3600, count: 24, offsetS: 60 * 60}, // hourly x24, start in 1h
|
||||
{intervalS: 0, offsetS: 6 * 3600}, // one-shot in 6h
|
||||
{intervalS: 86400, offsetS: 24 * 3600}, // daily, start tomorrow
|
||||
}
|
||||
for i := 0; i < opt.Counts.Plans && i < len(planShapes); i++ {
|
||||
ps := planShapes[i]
|
||||
p := r.makePlan(i, ps)
|
||||
if err := r.pg.CreatePlan(ctx, p); err != nil {
|
||||
return s, fmt.Errorf("create plan %d: %w", i, err)
|
||||
}
|
||||
s.PlansInserted++
|
||||
}
|
||||
|
||||
s.Duration = time.Since(start)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type planShape struct {
|
||||
intervalS int
|
||||
count int
|
||||
offsetS int
|
||||
}
|
||||
|
||||
// resetAll wipes the seed-managed rows in the unified races table
|
||||
// (only finished | cancelled; live rows are removed via lobby
|
||||
// cascade), plus the plan and queue tables. Drivers and clans are
|
||||
// kept (they're independent of races).
|
||||
func (r *Runner) resetAll(ctx context.Context) error {
|
||||
if _, err := r.pg.Exec(ctx,
|
||||
`DELETE FROM races WHERE status IN ('finished','cancelled')`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx,
|
||||
`TRUNCATE TABLE race_plans, race_queue`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range r.lb.ListRaces() {
|
||||
_ = r.lb.DeleteRace(m.ID)
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx, `TRUNCATE TABLE lobby_drivers`); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedDriversAndClans inserts the default driver and clan set if the
|
||||
// tables are empty. Idempotent: existing rows are left alone.
|
||||
func (r *Runner) seedDriversAndClans(ctx context.Context) error {
|
||||
clanCount, err := r.clansPg.Count(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count clans: %w", err)
|
||||
}
|
||||
if clanCount == 0 {
|
||||
now := time.Now().UnixMilli()
|
||||
for i, cs := range defaultClanSeeds {
|
||||
c := clans.Clan{
|
||||
ID: fmt.Sprintf("clan-seed-%03d", i+1),
|
||||
Tag: cs.tag,
|
||||
Name: cs.name,
|
||||
AvatarURL: fmt.Sprintf("https://cdn.example.com/clans/%s.png", cs.tag),
|
||||
CreatedMs: now,
|
||||
UpdatedMs: now,
|
||||
}
|
||||
if err := r.clansPg.Create(ctx, c); err != nil {
|
||||
return fmt.Errorf("insert clan %s: %w", cs.tag, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
drvCount, err := r.driversPg.Count(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count drivers: %w", err)
|
||||
}
|
||||
if drvCount == 0 {
|
||||
now := time.Now().UnixMilli()
|
||||
// Round-robin: each driver assigned to one of the seeded clans.
|
||||
clanIDs := make([]string, 0, len(defaultClanSeeds))
|
||||
for i := range defaultClanSeeds {
|
||||
clanIDs = append(clanIDs, fmt.Sprintf("clan-seed-%03d", i+1))
|
||||
}
|
||||
for i, ds := range defaultDriverSeeds {
|
||||
d := drivers.Driver{
|
||||
ID: fmt.Sprintf("driver-seed-%03d", i+1),
|
||||
Nickname: ds.nick,
|
||||
Name: ds.name,
|
||||
AvatarURL: fmt.Sprintf("https://cdn.example.com/drivers/%s.png", ds.nick),
|
||||
ClanID: clanIDs[i%len(clanIDs)],
|
||||
CreatedMs: now,
|
||||
UpdatedMs: now,
|
||||
}
|
||||
if err := r.driversPg.Create(ctx, d); err != nil {
|
||||
return fmt.Errorf("insert driver %s: %w", ds.nick, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadTracks fills r.trackIDs from the tracks table. Falls back to
|
||||
// fallbackTracks if the table is empty.
|
||||
func (r *Runner) loadTracks(ctx context.Context) error {
|
||||
ids, err := r.pg.ListTrackIDs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
ids = fallbackTracks
|
||||
}
|
||||
r.trackIDs = ids
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadDrivers fills r.driverIDs and r.driverInfo from the drivers table
|
||||
// in the DB, then propagates the profile (nickname/avatar/clan) to the
|
||||
// in-memory lobby drivers.
|
||||
func (r *Runner) loadDrivers(ctx context.Context) error {
|
||||
// Pull all drivers, ordered by nickname.
|
||||
clanByID := make(map[string]string)
|
||||
clansList, err := r.clansPg.List(ctx, 200, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list clans: %w", err)
|
||||
}
|
||||
for _, c := range clansList {
|
||||
clanByID[c.ID] = c.Tag
|
||||
}
|
||||
r.clanByID = clanByID
|
||||
|
||||
drvList, err := r.driversPg.List(ctx, 200, 0, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("list drivers: %w", err)
|
||||
}
|
||||
r.driverIDs = r.driverIDs[:0]
|
||||
r.driverInfo = r.driverInfo[:0]
|
||||
for _, d := range drvList {
|
||||
r.driverIDs = append(r.driverIDs, d.ID)
|
||||
r.driverInfo = append(r.driverInfo, seedDriver{
|
||||
ID: d.ID,
|
||||
Nickname: d.Nickname,
|
||||
Name: d.Name,
|
||||
ClanID: d.ClanID,
|
||||
ClanTag: clanByID[d.ClanID],
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (r *Runner) makeFinished(idx int) races.FinishedRace {
|
||||
rng := r.rng
|
||||
now := r.now
|
||||
// Spread across the last 30 days.
|
||||
back := time.Duration(30*24-rng.Intn(30*24-1)) * time.Hour
|
||||
finishedMs := now.Add(-back).UnixMilli()
|
||||
startedMs := finishedMs - randDuration(rng, 90, 600).Milliseconds()
|
||||
|
||||
track := r.trackIDs[rng.Intn(len(r.trackIDs))]
|
||||
laps := trackLaps(track)
|
||||
|
||||
// Pick 2..4 random drivers from the seeded set.
|
||||
shuffled := append([]seedDriver(nil), r.driverInfo...)
|
||||
rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })
|
||||
n := 2 + rng.Intn(3) // 2..4 drivers
|
||||
if n > len(shuffled) {
|
||||
n = len(shuffled)
|
||||
}
|
||||
picked := shuffled[:n]
|
||||
driverIDs := make([]string, n)
|
||||
for i, d := range picked {
|
||||
driverIDs[i] = d.ID
|
||||
}
|
||||
|
||||
winner := picked[rng.Intn(len(picked))]
|
||||
totalLaps := laps
|
||||
if rng.Intn(5) == 0 {
|
||||
// ~20% DNF/partial.
|
||||
totalLaps = rng.Intn(laps)
|
||||
}
|
||||
bestLapMs := int64(20_000 + rng.Intn(8_000))
|
||||
|
||||
// Podium: top-3 by total time. The winner always finishes; the
|
||||
// remaining drivers are sorted by a per-driver total time derived
|
||||
// from the winner's best lap.
|
||||
perDriver := make([]int64, len(picked))
|
||||
for i, d := range picked {
|
||||
if d.ID == winner.ID {
|
||||
perDriver[i] = int64(totalLaps) * bestLapMs
|
||||
} else {
|
||||
perDriver[i] = int64(totalLaps)*bestLapMs + int64(2+rng.Intn(15))*1000
|
||||
}
|
||||
}
|
||||
type dp struct {
|
||||
d seedDriver
|
||||
time int64
|
||||
}
|
||||
all := make([]dp, len(picked))
|
||||
for i := range picked {
|
||||
all[i] = dp{picked[i], perDriver[i]}
|
||||
}
|
||||
// Insertion sort by time ascending.
|
||||
for i := 1; i < len(all); i++ {
|
||||
for j := i; j > 0 && all[j-1].time > all[j].time; j-- {
|
||||
all[j-1], all[j] = all[j], all[j-1]
|
||||
}
|
||||
}
|
||||
podium := make([]races.PodiumEntry, 0, 3)
|
||||
for i := 0; i < len(all) && i < 3; i++ {
|
||||
podium = append(podium, races.PodiumEntry{
|
||||
Position: i + 1,
|
||||
DriverID: all[i].d.ID,
|
||||
Name: all[i].d.Nickname,
|
||||
TotalTimeMs: all[i].time,
|
||||
})
|
||||
}
|
||||
|
||||
return races.FinishedRace{
|
||||
ID: fmt.Sprintf("seed-finished-%03d", idx+1),
|
||||
Name: fmt.Sprintf("Seed Race #%d (%s)", idx+1, track),
|
||||
TrackID: track,
|
||||
MaxCars: 4,
|
||||
Laps: laps,
|
||||
TimeLimitS: 0,
|
||||
DriverIDs: driverIDs,
|
||||
Status: "finished",
|
||||
CreatedMs: finishedMs - randDuration(rng, 5, 120).Milliseconds(),
|
||||
StartedMs: startedMs,
|
||||
FinishedMs: finishedMs,
|
||||
DurationMs: finishedMs - startedMs,
|
||||
TotalLaps: totalLaps,
|
||||
TotalDrivers: len(driverIDs),
|
||||
WinnerDriverID: winner.ID,
|
||||
WinnerName: winner.Nickname,
|
||||
BestLapMs: bestLapMs,
|
||||
Podium: podium,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) createLive(status lobby.RaceStatus, idx int) (lobby.RaceMeta, error) {
|
||||
rng := r.rng
|
||||
track := r.trackIDs[rng.Intn(len(r.trackIDs))]
|
||||
|
||||
meta, err := r.lb.CreateRace(lobby.CreateRaceOptions{
|
||||
Name: fmt.Sprintf("Live %s #%d", track, idx+1),
|
||||
TrackID: track,
|
||||
MaxCars: 4,
|
||||
Laps: trackLaps(track),
|
||||
TimeLimitS: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return lobby.RaceMeta{}, err
|
||||
}
|
||||
// Add 1..3 random drivers into the race. We re-add them to refresh
|
||||
// any prior lobby state, then attach the profile (nickname/avatar/
|
||||
// clan) so the WS broadcast carries rich metadata.
|
||||
shuffled := append([]seedDriver(nil), r.driverInfo...)
|
||||
rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })
|
||||
n := 1 + rng.Intn(3)
|
||||
if n > len(shuffled) {
|
||||
n = len(shuffled)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
d := shuffled[i]
|
||||
r.lb.RemoveDriver(d.ID)
|
||||
if _, err := r.lb.AddDriver(d.ID, d.Name, ""); err != nil {
|
||||
continue
|
||||
}
|
||||
r.lb.SetDriverProfile(d.ID, d.Nickname, "", d.ClanID, d.ClanTag)
|
||||
if err := r.lb.AddDriverToRace(d.ID, meta.ID); err != nil {
|
||||
// ignore: race may be at capacity
|
||||
}
|
||||
}
|
||||
// Synchronously flush the race to the DB so the persistence mirror
|
||||
// (which uses detached goroutines) doesn't race with subsequent
|
||||
// status changes. The mirror calls in lobby.Service are still
|
||||
// fired; this upsert is a guarantee, not a replacement.
|
||||
if r.live != nil {
|
||||
flushCtx, flushCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer flushCancel()
|
||||
// Re-fetch the race from the in-memory lobby to get the latest
|
||||
// driver list and started_ms.
|
||||
cur, err := r.lb.GetRace(meta.ID)
|
||||
if err == nil {
|
||||
_ = r.live.UpsertRace(flushCtx, cur)
|
||||
for slot, did := range cur.DriverIDs {
|
||||
_ = r.live.AddDriver(flushCtx, cur.ID, did, slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = status
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func (r *Runner) makePlan(idx int, ps planShape) races.RacePlan {
|
||||
rng := r.rng
|
||||
now := r.now
|
||||
track := r.trackIDs[rng.Intn(len(r.trackIDs))]
|
||||
startAt := now.Add(time.Duration(ps.offsetS) * time.Second).UnixMilli()
|
||||
return races.RacePlan{
|
||||
ID: fmt.Sprintf("seed-plan-%03d", idx+1),
|
||||
Name: fmt.Sprintf("Seed Plan #%d (%s)", idx+1, track),
|
||||
TrackID: track,
|
||||
MaxCars: 4,
|
||||
Laps: trackLaps(track),
|
||||
TimeLimitS: 0,
|
||||
StartAtMs: startAt,
|
||||
IntervalS: ps.intervalS,
|
||||
Count: ps.count,
|
||||
Enabled: true,
|
||||
NextFireMs: startAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func trackLaps(track string) int {
|
||||
switch track {
|
||||
case "nordschleife":
|
||||
return 3
|
||||
case "monza":
|
||||
return 12
|
||||
case "spa":
|
||||
return 10
|
||||
case "silverstone":
|
||||
return 8
|
||||
case "suzuka":
|
||||
return 9
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
func randDuration(rng *rand.Rand, loSec, hiSec int) time.Duration {
|
||||
return time.Duration(loSec+rng.Intn(hiSec-loSec+1)) * time.Second
|
||||
}
|
||||
|
||||
func removeFrom(in []string, v string) []string {
|
||||
out := make([]string, 0, len(in)-1)
|
||||
for _, x := range in {
|
||||
if x != v {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
package races
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/x0gp/server/internal/lobby"
|
||||
)
|
||||
|
||||
// Service composes lobby.Service (live) with PgStore (finished/plans/queue)
|
||||
// and exposes the read/join methods used by the HTTP handlers and the
|
||||
// scheduler.
|
||||
type Service struct {
|
||||
pg *PgStore
|
||||
lobby *lobby.Service
|
||||
live *LiveStore // optional: when set, live reads go through DB
|
||||
now func() time.Time
|
||||
maxIDs int
|
||||
}
|
||||
|
||||
// NewService wires a Service. now is overridable for tests.
|
||||
func NewService(pg *PgStore, lb *lobby.Service) *Service {
|
||||
return &Service{
|
||||
pg: pg,
|
||||
lobby: lb,
|
||||
now: time.Now,
|
||||
maxIDs: 3,
|
||||
}
|
||||
}
|
||||
|
||||
// SetLiveStore installs the LiveStore (DB-backed live races). When set,
|
||||
// the read side of the races list pulls from Postgres instead of the
|
||||
// in-memory lobby. Writes still go through lobby.Service.
|
||||
func (s *Service) SetLiveStore(live *LiveStore) {
|
||||
s.live = live
|
||||
}
|
||||
|
||||
// RestoreFromDB rehydrates the in-memory lobby from the live_races and
|
||||
// lobby_drivers tables. Called at startup so that active races and
|
||||
// driver presence survive a server restart. If LiveStore is not
|
||||
// configured this is a no-op.
|
||||
func (s *Service) RestoreFromDB(ctx context.Context) (int, int, error) {
|
||||
if s.live == nil {
|
||||
return 0, 0, nil
|
||||
}
|
||||
races, err := s.live.ListAllRaces(ctx)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("restore races: %w", err)
|
||||
}
|
||||
drivers, err := s.live.ListAllDrivers(ctx)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("restore drivers: %w", err)
|
||||
}
|
||||
// Restore drivers first so the race restore can attach them.
|
||||
for _, d := range drivers {
|
||||
// We bypass the normal AddDriver because we don't want to
|
||||
// re-trigger a persistence write for already-persisted rows.
|
||||
// Use the internal helper if available; otherwise go through
|
||||
// AddDriver+SetDriverProfile and accept a no-op mirror.
|
||||
_, _ = s.lobby.AddDriver(d.ID, d.Name, "")
|
||||
s.lobby.SetDriverProfile(d.ID, d.Nickname, d.AvatarURL, d.ClanID, d.ClanTag)
|
||||
}
|
||||
for _, r := range races {
|
||||
// We bypass CreateRace to avoid duplicating persistence writes
|
||||
// for races that are already in the DB. AddRace (a new helper
|
||||
// on lobby.Service) inserts the race without mirroring.
|
||||
s.lobby.AddRace(r)
|
||||
for _, did := range r.DriverIDs {
|
||||
_ = s.lobby.AddDriverToRace(did, r.ID)
|
||||
}
|
||||
}
|
||||
return len(races), len(drivers), nil
|
||||
}
|
||||
|
||||
// SetMaxUpcoming overrides the default "next N" queue size (default 3).
|
||||
func (s *Service) SetMaxUpcoming(n int) {
|
||||
if n > 0 && n <= 16 {
|
||||
s.maxIDs = n
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List (keyset, mixed live + finished)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ListFilter is the parsed query for ListRaces.
|
||||
type ListFilter struct {
|
||||
Statuses []StatusFilter // resolved filter; empty = all
|
||||
TrackID string
|
||||
Cursor Cursor
|
||||
Limit int
|
||||
}
|
||||
|
||||
// RaceItem is one row in the paginated result.
|
||||
type RaceItem struct {
|
||||
Source string // "live" | "finished"
|
||||
Meta lobby.RaceMeta // always populated
|
||||
// Podium is populated for finished races only.
|
||||
Podium []PodiumEntry
|
||||
}
|
||||
|
||||
// ListResult is a keyset page.
|
||||
type ListResult struct {
|
||||
Items []RaceItem
|
||||
NextCursor string // empty when no more pages
|
||||
}
|
||||
|
||||
// ListRaces returns one keyset page of races matching the filter.
|
||||
//
|
||||
// Pagination strategy: live races (in-memory) are stable per snapshot but
|
||||
// don't have a DB-side index. To honour the "keyset" contract we page
|
||||
// live and finished sources independently and merge by sort key:
|
||||
//
|
||||
// * Live races use started_ms if set, otherwise created_ms.
|
||||
// * Finished races use finished_ms.
|
||||
//
|
||||
// Pages are pulled live-first when any "active" status is in the filter,
|
||||
// or finished-first when status=finished. The cursor encodes
|
||||
// ("live"|"finished", sort_ms, race_id) so successive pages stay in order.
|
||||
func (s *Service) ListRaces(ctx context.Context, f ListFilter) (ListResult, error) {
|
||||
limit := f.Limit
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
|
||||
wantFinished := false
|
||||
wantLive := false
|
||||
for _, st := range f.Statuses {
|
||||
if st == StatusAll {
|
||||
wantLive = true
|
||||
wantFinished = true
|
||||
break
|
||||
}
|
||||
if st.matchesLive("finished") || st == StatusFinished {
|
||||
wantFinished = true
|
||||
}
|
||||
if st.matchesLive("lobby") || st == StatusLobby ||
|
||||
st == StatusRacing {
|
||||
wantLive = true
|
||||
}
|
||||
}
|
||||
|
||||
// Parse cursor split (source/ms/id) if present.
|
||||
curSource, curMs, curID := "live", int64(0), ""
|
||||
if !f.Cursor.IsZero() {
|
||||
curSource, curMs, curID = splitCursor(f.Cursor)
|
||||
}
|
||||
|
||||
// Collect candidates from each source up to (limit+1) entries.
|
||||
out := make([]RaceItem, 0, limit+1)
|
||||
|
||||
if wantLive {
|
||||
live, err := s.collectLive(ctx, f.Statuses, f.TrackID, curSource, curMs, curID, limit+1)
|
||||
if err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
out = append(out, live...)
|
||||
}
|
||||
if wantFinished {
|
||||
finished, err := s.collectFinished(ctx, f.Statuses, f.TrackID, curSource, curMs, curID, limit+1)
|
||||
if err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
out = append(out, finished...)
|
||||
}
|
||||
|
||||
// Sort by (sort_ms DESC, id ASC), stable.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
mi := sortMs(out[i].Meta)
|
||||
mj := sortMs(out[j].Meta)
|
||||
if mi != mj {
|
||||
return mi > mj
|
||||
}
|
||||
return out[i].Meta.ID < out[j].Meta.ID
|
||||
})
|
||||
|
||||
hasMore := false
|
||||
if len(out) > limit {
|
||||
hasMore = true
|
||||
out = out[:limit]
|
||||
}
|
||||
|
||||
res := ListResult{Items: out}
|
||||
if hasMore && len(out) > 0 {
|
||||
last := out[len(out)-1]
|
||||
res.NextCursor = Cursor{
|
||||
Ms: sortMs(last.Meta),
|
||||
ID: last.Meta.ID,
|
||||
}.Encode()
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func sortMs(m lobby.RaceMeta) int64 {
|
||||
if m.Status == lobby.RaceStatusFinished {
|
||||
// Finished meta from PgStore has no FinishedMs field; StartedMs is the
|
||||
// best proxy for the original finished time within the lobby shape.
|
||||
if m.StartedMs > 0 {
|
||||
return m.StartedMs
|
||||
}
|
||||
return m.CreatedMs
|
||||
}
|
||||
if m.StartedMs > 0 {
|
||||
return m.StartedMs
|
||||
}
|
||||
return m.CreatedMs
|
||||
}
|
||||
|
||||
// collectLive fetches one page of live races. If s.live (LiveStore) is
|
||||
// set, the data comes from Postgres; otherwise it falls back to the
|
||||
// in-memory lobby.
|
||||
func (s *Service) collectLive(ctx context.Context, filters []StatusFilter, trackID, curSource string, curMs int64, curID string, limit int) ([]RaceItem, error) {
|
||||
if s.live != nil && curSource == "live" {
|
||||
return s.collectLiveDB(ctx, filters, trackID, curMs, curID, limit)
|
||||
}
|
||||
// Fallback: in-memory lobby. The in-memory branch still applies the
|
||||
// cursor manually because the legacy splitCursor format encodes the
|
||||
// source in the id suffix.
|
||||
all := s.lobby.ListRaces()
|
||||
out := make([]RaceItem, 0, limit)
|
||||
for _, r := range all {
|
||||
if !statusMatches(filters, string(r.Status)) {
|
||||
continue
|
||||
}
|
||||
if trackID != "" && r.TrackID != trackID {
|
||||
continue
|
||||
}
|
||||
sortKey := sortMs(r)
|
||||
if curSource == "live" {
|
||||
if sortKey < curMs || (sortKey == curMs && r.ID >= curID) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, RaceItem{Source: "live", Meta: r})
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) collectLiveDB(ctx context.Context, filters []StatusFilter, trackID string, curMs int64, curID string, limit int) ([]RaceItem, error) {
|
||||
statuses := filterToStatusStrings(filters)
|
||||
cur := Cursor{Ms: curMs, ID: curID}
|
||||
rows, err := s.live.ListLivePaged(ctx, statuses, trackID, cur, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list live: %w", err)
|
||||
}
|
||||
out := make([]RaceItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
// Skip anything that doesn't match the in-memory status filter
|
||||
// (DB already filtered; this is a safety belt).
|
||||
if !statusMatches(filters, string(r.Status)) {
|
||||
continue
|
||||
}
|
||||
out = append(out, RaceItem{Source: "live", Meta: r})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// filterToStatusStrings returns the list of status values that the DB
|
||||
// query should match. StatusAll expands to the three live statuses
|
||||
// (lobby, countdown, racing). The caller adds the finished source
|
||||
// separately.
|
||||
func filterToStatusStrings(filters []StatusFilter) []string {
|
||||
if len(filters) == 0 {
|
||||
return []string{string(lobby.RaceStatusLobby), string(lobby.RaceStatusCountdown), string(lobby.RaceStatusRacing)}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, f := range filters {
|
||||
switch f {
|
||||
case StatusAll:
|
||||
return []string{string(lobby.RaceStatusLobby), string(lobby.RaceStatusCountdown), string(lobby.RaceStatusRacing)}
|
||||
case StatusRacing:
|
||||
seen["racing"] = true
|
||||
case StatusLobby:
|
||||
seen["lobby"] = true
|
||||
case StatusFinished:
|
||||
// finished is served by collectFinished; skip here.
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for k := range seen {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) collectFinished(ctx context.Context, filters []StatusFilter, trackID, curSource string, curMs int64, curID string, limit int) ([]RaceItem, error) {
|
||||
if !statusWantsFinished(filters) {
|
||||
return nil, nil
|
||||
}
|
||||
cur := Cursor{}
|
||||
if curSource == "finished" {
|
||||
cur = Cursor{Ms: curMs, ID: curID}
|
||||
}
|
||||
rows, err := s.pg.ListFinished(ctx, trackID, cur, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]RaceItem, 0, len(rows))
|
||||
for _, f := range rows {
|
||||
out = append(out, RaceItem{Source: "finished", Meta: f.ToLobbyMeta(), Podium: f.Podium})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func statusMatches(filters []StatusFilter, liveStatus string) bool {
|
||||
if len(filters) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, f := range filters {
|
||||
if f == StatusAll {
|
||||
return true
|
||||
}
|
||||
if f.matchesLive(liveStatus) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// statusWantsFinished is true if the filter list allows finished races
|
||||
// through. Used to gate the Postgres query in collectFinished.
|
||||
func statusWantsFinished(filters []StatusFilter) bool {
|
||||
if len(filters) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, f := range filters {
|
||||
if f == StatusAll || f == StatusFinished {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitCursor is a no-op shim; we encode source in the id field for v1
|
||||
// simplicity (suffix ":src"). Returns ("live"|"finished", ms, id).
|
||||
func splitCursor(c Cursor) (string, int64, string) {
|
||||
id := c.ID
|
||||
src := "live"
|
||||
// Convention: id may end with ":finished" or ":live". If absent, treat as live.
|
||||
for _, suf := range []string{":finished", ":live"} {
|
||||
if len(id) > len(suf) && id[len(id)-len(suf):] == suf {
|
||||
id = id[:len(id)-len(suf)]
|
||||
src = suf[1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
return src, c.Ms, id
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upcoming (next N) and queue join
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpcomingItem is one row in the upcoming list.
|
||||
type UpcomingItem struct {
|
||||
Meta lobby.RaceMeta
|
||||
QueueLen int
|
||||
PlanID string
|
||||
StartAtMs int64 // best-effort: StartedMs for live races, otherwise CreatedMs+offset
|
||||
}
|
||||
|
||||
// Upcoming returns the next N open (lobby|countdown) races, soonest first.
|
||||
func (s *Service) Upcoming(ctx context.Context, limit int) ([]UpcomingItem, error) {
|
||||
if limit <= 0 {
|
||||
limit = s.maxIDs
|
||||
}
|
||||
if limit > 16 {
|
||||
limit = 16
|
||||
}
|
||||
// When LiveStore is configured, read upcoming from Postgres.
|
||||
if s.live != nil {
|
||||
return s.upcomingDB(ctx, limit)
|
||||
}
|
||||
all := s.lobby.ListRaces()
|
||||
out := make([]UpcomingItem, 0, limit)
|
||||
for _, r := range all {
|
||||
if r.Status != lobby.RaceStatusLobby && r.Status != lobby.RaceStatusCountdown {
|
||||
continue
|
||||
}
|
||||
n, err := s.pg.CountQueueByRace(ctx, r.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startAt := r.StartedMs
|
||||
if startAt == 0 {
|
||||
startAt = r.CreatedMs
|
||||
}
|
||||
out = append(out, UpcomingItem{
|
||||
Meta: r,
|
||||
QueueLen: n,
|
||||
StartAtMs: startAt,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].StartAtMs != out[j].StartAtMs {
|
||||
return out[i].StartAtMs < out[j].StartAtMs
|
||||
}
|
||||
return out[i].Meta.ID < out[j].Meta.ID
|
||||
})
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) upcomingDB(ctx context.Context, limit int) ([]UpcomingItem, error) {
|
||||
rows, err := s.live.ListUpcoming(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list upcoming: %w", err)
|
||||
}
|
||||
out := make([]UpcomingItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
n, err := s.pg.CountQueueByRace(ctx, r.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startAt := r.StartedMs
|
||||
if startAt == 0 {
|
||||
startAt = r.CreatedMs
|
||||
}
|
||||
out = append(out, UpcomingItem{
|
||||
Meta: r,
|
||||
QueueLen: n,
|
||||
StartAtMs: startAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// JoinUpcomingResult is what JoinUpcoming returns.
|
||||
type JoinUpcomingResult struct {
|
||||
Joined []QueueEntry
|
||||
Skipped []string // race ids that were already at capacity
|
||||
}
|
||||
|
||||
// JoinUpcoming puts the driver into the queue for each of the next N
|
||||
// upcoming races. Idempotent per (driver, race).
|
||||
func (s *Service) JoinUpcoming(ctx context.Context, driverID string, limit int) (JoinUpcomingResult, error) {
|
||||
if driverID == "" {
|
||||
return JoinUpcomingResult{}, fmt.Errorf("%w: driver_id required", ErrInvalidInput)
|
||||
}
|
||||
up, err := s.Upcoming(ctx, limit)
|
||||
if err != nil {
|
||||
return JoinUpcomingResult{}, err
|
||||
}
|
||||
res := JoinUpcomingResult{Joined: make([]QueueEntry, 0, len(up))}
|
||||
for _, u := range up {
|
||||
// Skip races that are full; surfaced in Skipped for the caller.
|
||||
if len(u.Meta.DriverIDs) >= u.Meta.MaxCars {
|
||||
res.Skipped = append(res.Skipped, u.Meta.ID)
|
||||
continue
|
||||
}
|
||||
// Best-effort auto-attach: if the race is still lobby and the driver
|
||||
// can fit, AddDriverToRace moves them in. The queue entry remains
|
||||
// for diagnostics.
|
||||
if u.Meta.Status == lobby.RaceStatusLobby && len(u.Meta.DriverIDs) < u.Meta.MaxCars {
|
||||
_ = s.lobby.AddDriverToRace(driverID, u.Meta.ID)
|
||||
}
|
||||
q, err := s.pg.Enqueue(ctx, driverID, u.Meta.ID, u.PlanID)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Joined = append(res.Joined, q)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// LeaveQueue removes a single (driver, race) entry.
|
||||
func (s *Service) LeaveQueue(ctx context.Context, driverID, raceID string) error {
|
||||
return s.pg.Dequeue(ctx, driverID, raceID)
|
||||
}
|
||||
|
||||
// ListQueue returns the driver's queue entries.
|
||||
func (s *Service) ListQueue(ctx context.Context, driverID string) ([]QueueEntry, error) {
|
||||
return s.pg.ListQueueByDriver(ctx, driverID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finished-race snapshot hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SnapshotFinished is the input to the "persist on finish" hook. Race is
|
||||
// a copy of the in-memory meta. Result is filled by the engine.
|
||||
type SnapshotFinished struct {
|
||||
Meta lobby.RaceMeta
|
||||
FinishedMs int64
|
||||
TotalLaps int
|
||||
WinnerDriverID string
|
||||
WinnerName string
|
||||
BestLapMs int64
|
||||
}
|
||||
|
||||
// PersistFinished upserts the finished race into Postgres.
|
||||
func (s *Service) PersistFinished(ctx context.Context, sf SnapshotFinished) error {
|
||||
startedMs := sf.Meta.StartedMs
|
||||
finishedMs := sf.FinishedMs
|
||||
if finishedMs == 0 {
|
||||
finishedMs = s.now().UnixMilli()
|
||||
}
|
||||
r := FinishedRace{
|
||||
ID: sf.Meta.ID,
|
||||
Name: sf.Meta.Name,
|
||||
TrackID: sf.Meta.TrackID,
|
||||
MaxCars: sf.Meta.MaxCars,
|
||||
Laps: sf.Meta.Laps,
|
||||
TimeLimitS: sf.Meta.TimeLimitS,
|
||||
DriverIDs: append([]string(nil), sf.Meta.DriverIDs...),
|
||||
Status: "finished",
|
||||
CreatedMs: sf.Meta.CreatedMs,
|
||||
StartedMs: startedMs,
|
||||
FinishedMs: finishedMs,
|
||||
DurationMs: finishedMs - startedMs,
|
||||
TotalLaps: sf.TotalLaps,
|
||||
TotalDrivers: len(sf.Meta.DriverIDs),
|
||||
WinnerDriverID: sf.WinnerDriverID,
|
||||
WinnerName: sf.WinnerName,
|
||||
BestLapMs: sf.BestLapMs,
|
||||
}
|
||||
return s.pg.InsertFinished(ctx, r)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Race plans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreatePlanInput is what the HTTP handler hands to the service.
|
||||
type CreatePlanInput struct {
|
||||
ID string
|
||||
Name string
|
||||
TrackID string
|
||||
MaxCars int
|
||||
Laps int
|
||||
TimeLimitS int
|
||||
StartAtMs int64
|
||||
IntervalS int
|
||||
Count int
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// CreatePlan validates and persists a plan.
|
||||
func (s *Service) CreatePlan(ctx context.Context, in CreatePlanInput) (RacePlan, error) {
|
||||
if in.Name == "" {
|
||||
return RacePlan{}, fmt.Errorf("%w: name required", ErrInvalidInput)
|
||||
}
|
||||
if in.MaxCars < 1 || in.MaxCars > 8 {
|
||||
return RacePlan{}, fmt.Errorf("%w: max_cars must be 1..8", ErrInvalidInput)
|
||||
}
|
||||
if in.StartAtMs <= 0 {
|
||||
return RacePlan{}, fmt.Errorf("%w: start_at_ms required", ErrInvalidInput)
|
||||
}
|
||||
if in.IntervalS < 0 {
|
||||
return RacePlan{}, fmt.Errorf("%w: interval_s must be >= 0", ErrInvalidInput)
|
||||
}
|
||||
if in.Count < 0 {
|
||||
return RacePlan{}, fmt.Errorf("%w: count must be >= 0", ErrInvalidInput)
|
||||
}
|
||||
if in.ID == "" {
|
||||
in.ID = fmt.Sprintf("plan-%d-%d", s.now().UnixMilli(), time.Now().UnixNano()%1000)
|
||||
}
|
||||
if in.TrackID == "" {
|
||||
in.TrackID = "default"
|
||||
}
|
||||
p := RacePlan{
|
||||
ID: in.ID,
|
||||
Name: in.Name,
|
||||
TrackID: in.TrackID,
|
||||
MaxCars: in.MaxCars,
|
||||
Laps: in.Laps,
|
||||
TimeLimitS: in.TimeLimitS,
|
||||
StartAtMs: in.StartAtMs,
|
||||
IntervalS: in.IntervalS,
|
||||
Count: in.Count,
|
||||
Enabled: in.Enabled,
|
||||
NextFireMs: in.StartAtMs,
|
||||
}
|
||||
if err := s.pg.CreatePlan(ctx, p); err != nil {
|
||||
return RacePlan{}, err
|
||||
}
|
||||
return s.pg.GetPlan(ctx, p.ID)
|
||||
}
|
||||
|
||||
// ListPlans returns plans in start_at ascending order.
|
||||
func (s *Service) ListPlans(ctx context.Context, cur Cursor, limit int) ([]RacePlan, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
return s.pg.ListPlans(ctx, cur, limit)
|
||||
}
|
||||
|
||||
// DeletePlan removes a plan by id.
|
||||
func (s *Service) DeletePlan(ctx context.Context, id string) error {
|
||||
return s.pg.DeletePlan(ctx, id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Scheduler materialises due race plans into lobby races every tick.
|
||||
type Scheduler struct {
|
||||
pg *PgStore
|
||||
lobby *lobby.Service
|
||||
tick time.Duration
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewScheduler creates a scheduler. tick default is 5s.
|
||||
func NewScheduler(pg *PgStore, lb *lobby.Service, tick time.Duration) *Scheduler {
|
||||
if tick <= 0 {
|
||||
tick = 5 * time.Second
|
||||
}
|
||||
return &Scheduler{
|
||||
pg: pg,
|
||||
lobby: lb,
|
||||
tick: tick,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Run blocks until Stop is called. Safe to launch in a goroutine.
|
||||
func (s *Scheduler) Run(ctx context.Context) {
|
||||
defer close(s.doneCh)
|
||||
t := time.NewTicker(s.tick)
|
||||
defer t.Stop()
|
||||
// Fire once on start so newly-enabled plans materialise promptly.
|
||||
s.tickOnce(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-t.C:
|
||||
s.tickOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop signals the scheduler to exit.
|
||||
func (s *Scheduler) Stop() {
|
||||
s.once.Do(func() { close(s.stopCh) })
|
||||
}
|
||||
|
||||
// Done blocks until the scheduler goroutine has returned.
|
||||
func (s *Scheduler) Done() <-chan struct{} { return s.doneCh }
|
||||
|
||||
func (s *Scheduler) tickOnce(ctx context.Context) {
|
||||
now := s.now().UnixMilli()
|
||||
plans, err := s.pg.DuePlans(ctx, now, 32)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, p := range plans {
|
||||
meta, err := s.lobby.CreateRace(lobby.CreateRaceOptions{
|
||||
Name: p.Name,
|
||||
TrackID: p.TrackID,
|
||||
MaxCars: p.MaxCars,
|
||||
Laps: p.Laps,
|
||||
TimeLimitS: p.TimeLimitS,
|
||||
})
|
||||
if err != nil {
|
||||
// Could not create; advance anyway to avoid hot-loop on the
|
||||
// same plan.
|
||||
next := p.NextFireMs
|
||||
if p.IntervalS > 0 {
|
||||
next = next + int64(p.IntervalS)*1000
|
||||
}
|
||||
_ = s.pg.AdvancePlan(ctx, p.ID, next)
|
||||
continue
|
||||
}
|
||||
// Compute next fire time.
|
||||
next := p.NextFireMs
|
||||
if p.IntervalS > 0 {
|
||||
next = p.NextFireMs + int64(p.IntervalS)*1000
|
||||
}
|
||||
_ = s.advance(ctx, p, next)
|
||||
// Auto-attach queued drivers to the new race (best-effort).
|
||||
s.attachQueued(ctx, meta.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) advance(ctx context.Context, p RacePlan, nextFireMs int64) error {
|
||||
if nextFireMs == 0 && p.IntervalS == 0 {
|
||||
// Single-shot: disable.
|
||||
return s.pg.SetPlanEnabled(ctx, p.ID, false)
|
||||
}
|
||||
return s.pg.AdvancePlan(ctx, p.ID, nextFireMs)
|
||||
}
|
||||
|
||||
func (s *Scheduler) attachQueued(ctx context.Context, raceID string) {
|
||||
entries, err := s.pg.ListQueueByRace(ctx, raceID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, q := range entries {
|
||||
_, _ = s.lobby.AddDriver(q.DriverID, "", "") //nolint:errcheck // best-effort
|
||||
_ = s.lobby.AddDriverToRace(q.DriverID, raceID)
|
||||
_ = s.pg.Dequeue(ctx, q.DriverID, raceID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) now() time.Time { return time.Now() }
|
||||
@@ -0,0 +1,803 @@
|
||||
package races
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/x0gp/server/internal/lobby"
|
||||
)
|
||||
|
||||
// PodiumEntry is one row in the top-3 of a finished race. Ordered by
|
||||
// Position (1..3). DriverID/Name are denormalised from the future
|
||||
// drivers table; TotalTimeMs is the driver's total race time.
|
||||
type PodiumEntry struct {
|
||||
Position int `json:"position" example:"1"`
|
||||
DriverID string `json:"driver_id" example:"driver-alice"`
|
||||
Name string `json:"name" example:"driver-alice"`
|
||||
TotalTimeMs int64 `json:"total_time_ms" example:"123456"`
|
||||
}
|
||||
|
||||
// FinishedRace is the on-disk shape of a completed race. Mirrors the
|
||||
// lobby.RaceMeta plus outcome fields populated by the engine at finish.
|
||||
type FinishedRace struct {
|
||||
ID string
|
||||
Name string
|
||||
TrackID string
|
||||
MaxCars int
|
||||
Laps int
|
||||
TimeLimitS int
|
||||
DriverIDs []string
|
||||
Status string // "finished" | "cancelled"
|
||||
CreatedMs int64
|
||||
StartedMs int64
|
||||
FinishedMs int64
|
||||
DurationMs int64
|
||||
TotalLaps int
|
||||
TotalDrivers int
|
||||
WinnerDriverID string
|
||||
WinnerName string
|
||||
BestLapMs int64
|
||||
Podium []PodiumEntry
|
||||
}
|
||||
|
||||
// RacePlan is a recurring or one-off scheduled race description.
|
||||
type RacePlan struct {
|
||||
ID string
|
||||
Name string
|
||||
TrackID string
|
||||
MaxCars int
|
||||
Laps int
|
||||
TimeLimitS int
|
||||
StartAtMs int64
|
||||
IntervalS int
|
||||
Count int
|
||||
Enabled bool
|
||||
CreatedMs int64
|
||||
UpdatedMs int64
|
||||
NextFireMs int64
|
||||
FiresDone int
|
||||
}
|
||||
|
||||
// QueueEntry is a single (driver, race) subscription.
|
||||
type QueueEntry struct {
|
||||
DriverID string
|
||||
RaceID string
|
||||
PlanID string
|
||||
EnqueuedMs int64
|
||||
}
|
||||
|
||||
// PgStore is the Postgres-backed half of the races package.
|
||||
type PgStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPgStore wraps an open pgxpool.
|
||||
func NewPgStore(pool *pgxpool.Pool) *PgStore {
|
||||
return &PgStore{pool: pool}
|
||||
}
|
||||
|
||||
// Exec exposes a raw Exec for the seeder / maintenance scripts.
|
||||
func (s *PgStore) Exec(ctx context.Context, sql string) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, sql)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// ListTrackIDs returns all track ids from the tracks table. Used by
|
||||
// the seeder to bind race_plans/finished_races to real catalog entries.
|
||||
func (s *PgStore) ListTrackIDs(ctx context.Context) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id FROM tracks ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list track ids: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live race surface (status in lobby | countdown | racing).
|
||||
//
|
||||
// The unified `races` table holds both live and finished rows. Methods
|
||||
// below are the write-side mirror for lobby.Service; reads flow through
|
||||
// races.Service via ListRacesPaged (status filter) and ListUpcoming.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpsertLive mirrors a lobby.RaceMeta row. Only used for status in
|
||||
// (lobby, countdown, racing). Status is enforced at the call site.
|
||||
func (s *PgStore) UpsertLive(ctx context.Context, r lobby.RaceMeta) error {
|
||||
now := time.Now().UnixMilli()
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO races
|
||||
(id, name, track_id, max_cars, laps, time_limit_s, status,
|
||||
created_ms, started_ms, finished_ms, updated_ms, podium)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10, '[]'::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
track_id = EXCLUDED.track_id,
|
||||
max_cars = EXCLUDED.max_cars,
|
||||
laps = EXCLUDED.laps,
|
||||
time_limit_s = EXCLUDED.time_limit_s,
|
||||
status = EXCLUDED.status,
|
||||
started_ms = EXCLUDED.started_ms,
|
||||
updated_ms = EXCLUDED.updated_ms`,
|
||||
r.ID, r.Name, r.TrackID, r.MaxCars, r.Laps, r.TimeLimitS, string(r.Status),
|
||||
r.CreatedMs, r.StartedMs, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert live race %s: %w", r.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteLive removes a live race row. The FK from race_drivers has
|
||||
// CASCADE so the related drivers are also removed.
|
||||
func (s *PgStore) DeleteLive(ctx context.Context, raceID string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM races WHERE id = $1 AND status IN ('lobby','countdown','racing')`, raceID)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddLiveDriver adds a driver to a live race (slot=position). Idempotent.
|
||||
func (s *PgStore) AddLiveDriver(ctx context.Context, raceID, driverID string, slot int) error {
|
||||
now := time.Now().UnixMilli()
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO race_drivers (race_id, driver_id, slot, joined_ms)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (race_id, driver_id) DO UPDATE SET slot = EXCLUDED.slot`,
|
||||
raceID, driverID, slot, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add live driver %s to %s: %w", driverID, raceID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveLiveDriver removes a driver from a live race.
|
||||
func (s *PgStore) RemoveLiveDriver(ctx context.Context, raceID, driverID string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM race_drivers WHERE race_id = $1 AND driver_id = $2`,
|
||||
raceID, driverID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetLiveStatus updates the status (and started_ms) of a live race.
|
||||
func (s *PgStore) SetLiveStatus(ctx context.Context, raceID string, status lobby.RaceStatus, startedMs int64) error {
|
||||
now := time.Now().UnixMilli()
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE races
|
||||
SET status = $2, started_ms = $3, updated_ms = $4
|
||||
WHERE id = $1 AND status IN ('lobby','countdown','racing')`,
|
||||
raceID, string(status), startedMs, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListLivePaged returns one keyset page of live races.
|
||||
func (s *PgStore) ListLivePaged(ctx context.Context, statuses []string, trackID string, cur Cursor, limit int) ([]lobby.RaceMeta, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
args := []any{}
|
||||
conds := []string{"status IN ('lobby','countdown','racing')"}
|
||||
if len(statuses) > 0 {
|
||||
args = append(args, statuses)
|
||||
conds = append(conds, fmt.Sprintf("status = ANY($%d)", len(args)))
|
||||
}
|
||||
if trackID != "" {
|
||||
args = append(args, trackID)
|
||||
conds = append(conds, fmt.Sprintf("track_id = $%d", len(args)))
|
||||
}
|
||||
if !cur.IsZero() {
|
||||
args = append(args, cur.Ms, cur.ID)
|
||||
conds = append(conds, fmt.Sprintf("(COALESCE(started_ms, created_ms), id) < ($%d, $%d)", len(args)-1, len(args)))
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
q := `SELECT id, name, track_id, max_cars, laps, time_limit_s, status,
|
||||
created_ms, started_ms
|
||||
FROM races
|
||||
WHERE ` + joinAnd(conds) + `
|
||||
ORDER BY COALESCE(started_ms, created_ms) DESC, id ASC
|
||||
LIMIT $` + fmt.Sprintf("%d", len(args))
|
||||
rows, err := s.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]lobby.RaceMeta, 0)
|
||||
for rows.Next() {
|
||||
var r lobby.RaceMeta
|
||||
var status string
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.TrackID, &r.MaxCars, &r.Laps, &r.TimeLimitS,
|
||||
&status, &r.CreatedMs, &r.StartedMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Status = lobby.RaceStatus(status)
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
ids, err := s.listDriverIDs(ctx, out[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i].DriverIDs = ids
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListLiveUpcoming returns up to limit live races in (lobby, countdown)
|
||||
// ordered soonest first.
|
||||
func (s *PgStore) ListLiveUpcoming(ctx context.Context, limit int) ([]lobby.RaceMeta, error) {
|
||||
if limit <= 0 {
|
||||
limit = 3
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, name, track_id, max_cars, laps, time_limit_s, status,
|
||||
created_ms, started_ms
|
||||
FROM races
|
||||
WHERE status IN ('lobby', 'countdown')
|
||||
ORDER BY COALESCE(started_ms, created_ms) ASC, id ASC
|
||||
LIMIT $1`, limit+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]lobby.RaceMeta, 0)
|
||||
for rows.Next() {
|
||||
var r lobby.RaceMeta
|
||||
var status string
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.TrackID, &r.MaxCars, &r.Laps, &r.TimeLimitS,
|
||||
&status, &r.CreatedMs, &r.StartedMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Status = lobby.RaceStatus(status)
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
ids, err := s.listDriverIDs(ctx, out[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i].DriverIDs = ids
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetLive fetches a single live race.
|
||||
func (s *PgStore) GetLive(ctx context.Context, id string) (lobby.RaceMeta, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, name, track_id, max_cars, laps, time_limit_s, status,
|
||||
created_ms, started_ms
|
||||
FROM races WHERE id = $1 AND status IN ('lobby','countdown','racing')`, id)
|
||||
var r lobby.RaceMeta
|
||||
var status string
|
||||
if err := row.Scan(&r.ID, &r.Name, &r.TrackID, &r.MaxCars, &r.Laps, &r.TimeLimitS,
|
||||
&status, &r.CreatedMs, &r.StartedMs); err != nil {
|
||||
return lobby.RaceMeta{}, err
|
||||
}
|
||||
r.Status = lobby.RaceStatus(status)
|
||||
ids, err := s.listDriverIDs(ctx, id)
|
||||
if err != nil {
|
||||
return lobby.RaceMeta{}, err
|
||||
}
|
||||
r.DriverIDs = ids
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// ListAllRaces returns every row in the races table (live+finished).
|
||||
// Used by RestoreFromDB to rehydrate the lobby.
|
||||
func (s *PgStore) ListAllRaces(ctx context.Context) ([]lobby.RaceMeta, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, name, track_id, max_cars, laps, time_limit_s, status,
|
||||
created_ms, started_ms
|
||||
FROM races
|
||||
WHERE status IN ('lobby','countdown','racing')
|
||||
ORDER BY created_ms ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]lobby.RaceMeta, 0)
|
||||
for rows.Next() {
|
||||
var r lobby.RaceMeta
|
||||
var status string
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.TrackID, &r.MaxCars, &r.Laps, &r.TimeLimitS,
|
||||
&status, &r.CreatedMs, &r.StartedMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Status = lobby.RaceStatus(status)
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
ids, err := s.listDriverIDs(ctx, out[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i].DriverIDs = ids
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PgStore) listDriverIDs(ctx context.Context, raceID string) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT driver_id FROM race_drivers WHERE race_id = $1 ORDER BY slot ASC, joined_ms ASC`,
|
||||
raceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertLobbyDriver mirrors a lobby.DriverMeta row.
|
||||
func (s *PgStore) UpsertLobbyDriver(ctx context.Context, d lobby.DriverMeta) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO lobby_drivers
|
||||
(driver_id, name, nickname, avatar_url, clan_id, clan_tag,
|
||||
status, current_race_id, connected_ms, last_seen_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (driver_id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
nickname = EXCLUDED.nickname,
|
||||
avatar_url = EXCLUDED.avatar_url,
|
||||
clan_id = EXCLUDED.clan_id,
|
||||
clan_tag = EXCLUDED.clan_tag,
|
||||
status = EXCLUDED.status,
|
||||
current_race_id = EXCLUDED.current_race_id,
|
||||
last_seen_ms = EXCLUDED.last_seen_ms`,
|
||||
d.ID, d.Name, d.Nickname, d.AvatarURL, d.ClanID, d.ClanTag,
|
||||
string(d.Status), d.RaceID, d.ConnectedMs, d.LastSeenMs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert lobby driver %s: %w", d.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteLobbyDriver removes a lobby driver row.
|
||||
func (s *PgStore) DeleteLobbyDriver(ctx context.Context, driverID string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM lobby_drivers WHERE driver_id = $1`, driverID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListLobbyDrivers returns all lobby drivers.
|
||||
func (s *PgStore) ListLobbyDrivers(ctx context.Context) ([]lobby.DriverMeta, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT driver_id, name, nickname, avatar_url, clan_id, clan_tag,
|
||||
status, current_race_id, connected_ms, last_seen_ms
|
||||
FROM lobby_drivers ORDER BY last_seen_ms DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]lobby.DriverMeta, 0)
|
||||
for rows.Next() {
|
||||
var d lobby.DriverMeta
|
||||
var status string
|
||||
if err := rows.Scan(&d.ID, &d.Name, &d.Nickname, &d.AvatarURL, &d.ClanID, &d.ClanTag,
|
||||
&status, &d.RaceID, &d.ConnectedMs, &d.LastSeenMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Status = lobby.DriverStatus(status)
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// finished_races
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// InsertFinished upserts a finished race row.
|
||||
func (s *PgStore) InsertFinished(ctx context.Context, r FinishedRace) error {
|
||||
podium := r.Podium
|
||||
if podium == nil {
|
||||
podium = []PodiumEntry{}
|
||||
}
|
||||
podiumJSON, err := json.Marshal(podium)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal podium: %w", err)
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO races
|
||||
(id, name, track_id, max_cars, laps, time_limit_s,
|
||||
driver_ids, status, created_ms, started_ms, finished_ms, duration_ms,
|
||||
total_laps, total_drivers, winner_driver_id, winner_name, best_lap_ms,
|
||||
podium)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
status = EXCLUDED.status,
|
||||
finished_ms = EXCLUDED.finished_ms,
|
||||
duration_ms = EXCLUDED.duration_ms,
|
||||
total_laps = EXCLUDED.total_laps,
|
||||
total_drivers = EXCLUDED.total_drivers,
|
||||
winner_driver_id = EXCLUDED.winner_driver_id,
|
||||
winner_name = EXCLUDED.winner_name,
|
||||
best_lap_ms = EXCLUDED.best_lap_ms,
|
||||
podium = EXCLUDED.podium`,
|
||||
r.ID, r.Name, r.TrackID, r.MaxCars, r.Laps, r.TimeLimitS,
|
||||
r.DriverIDs, r.Status, r.CreatedMs, r.StartedMs, r.FinishedMs, r.DurationMs,
|
||||
r.TotalLaps, r.TotalDrivers, r.WinnerDriverID, r.WinnerName, r.BestLapMs,
|
||||
podiumJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert finished race %s: %w", r.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListFinished returns finished races matching the filter using keyset
|
||||
// pagination on (finished_ms DESC, id DESC). The cursor's Ms is the
|
||||
// last finished_ms from the previous page; rows strictly older are
|
||||
// returned. Tie-breaker on id.
|
||||
func (s *PgStore) ListFinished(ctx context.Context, trackID string, cur Cursor, limit int) ([]FinishedRace, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
args := []any{}
|
||||
conds := []string{"status IN ('finished','cancelled')"}
|
||||
if !cur.IsZero() {
|
||||
args = append(args, cur.Ms, cur.ID)
|
||||
conds = append(conds, fmt.Sprintf("(finished_ms, id) < ($%d, $%d)", len(args)-1, len(args)))
|
||||
}
|
||||
if trackID != "" {
|
||||
args = append(args, trackID)
|
||||
conds = append(conds, fmt.Sprintf("track_id = $%d", len(args)))
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
q := `SELECT id, name, track_id, max_cars, laps, time_limit_s,
|
||||
driver_ids, status, created_ms, started_ms, finished_ms, duration_ms,
|
||||
total_laps, total_drivers, winner_driver_id, winner_name, best_lap_ms,
|
||||
podium
|
||||
FROM races
|
||||
WHERE ` + joinAnd(conds) + `
|
||||
ORDER BY finished_ms DESC, id DESC
|
||||
LIMIT $` + fmt.Sprintf("%d", len(args))
|
||||
rows, err := s.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list finished: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanFinished(rows)
|
||||
}
|
||||
|
||||
func scanFinished(rows pgx.Rows) ([]FinishedRace, error) {
|
||||
out := make([]FinishedRace, 0)
|
||||
for rows.Next() {
|
||||
var r FinishedRace
|
||||
var podiumJSON []byte
|
||||
if err := rows.Scan(
|
||||
&r.ID, &r.Name, &r.TrackID,
|
||||
&r.MaxCars, &r.Laps, &r.TimeLimitS, &r.DriverIDs, &r.Status,
|
||||
&r.CreatedMs, &r.StartedMs, &r.FinishedMs, &r.DurationMs,
|
||||
&r.TotalLaps, &r.TotalDrivers, &r.WinnerDriverID, &r.WinnerName, &r.BestLapMs,
|
||||
&podiumJSON,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(podiumJSON) > 0 {
|
||||
if err := json.Unmarshal(podiumJSON, &r.Podium); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal podium: %w", err)
|
||||
}
|
||||
}
|
||||
if r.Podium == nil {
|
||||
r.Podium = []PodiumEntry{}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func joinAnd(parts []string) string {
|
||||
out := ""
|
||||
for i, p := range parts {
|
||||
if i > 0 {
|
||||
out += " AND "
|
||||
}
|
||||
out += p
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// race_plans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreatePlan inserts a new race plan.
|
||||
func (s *PgStore) CreatePlan(ctx context.Context, p RacePlan) error {
|
||||
now := time.Now().UnixMilli()
|
||||
if p.CreatedMs == 0 {
|
||||
p.CreatedMs = now
|
||||
}
|
||||
p.UpdatedMs = now
|
||||
if p.NextFireMs == 0 {
|
||||
p.NextFireMs = p.StartAtMs
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO race_plans
|
||||
(id, name, track_id, max_cars, laps, time_limit_s,
|
||||
start_at_ms, interval_s, count,
|
||||
enabled, created_ms, updated_ms, next_fire_ms, fires_done)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
|
||||
p.ID, p.Name, p.TrackID, p.MaxCars, p.Laps, p.TimeLimitS,
|
||||
p.StartAtMs, p.IntervalS, p.Count,
|
||||
p.Enabled, p.CreatedMs, p.UpdatedMs, p.NextFireMs, p.FiresDone)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return fmt.Errorf("%w: plan %s", ErrAlreadyExists, p.ID)
|
||||
}
|
||||
return fmt.Errorf("insert plan: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPlan loads a plan by id.
|
||||
func (s *PgStore) GetPlan(ctx context.Context, id string) (RacePlan, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, name, track_id, max_cars, laps, time_limit_s,
|
||||
start_at_ms, interval_s, count,
|
||||
enabled, created_ms, updated_ms, next_fire_ms, fires_done
|
||||
FROM race_plans WHERE id = $1`, id)
|
||||
var p RacePlan
|
||||
if err := row.Scan(
|
||||
&p.ID, &p.Name, &p.TrackID, &p.MaxCars, &p.Laps, &p.TimeLimitS,
|
||||
&p.StartAtMs, &p.IntervalS, &p.Count,
|
||||
&p.Enabled, &p.CreatedMs, &p.UpdatedMs, &p.NextFireMs, &p.FiresDone,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return RacePlan{}, ErrNotFound
|
||||
}
|
||||
return RacePlan{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ListPlans returns plans in ascending start order, keyset-paginated.
|
||||
func (s *PgStore) ListPlans(ctx context.Context, cur Cursor, limit int) ([]RacePlan, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
args := []any{}
|
||||
conds := []string{"1=1"}
|
||||
if !cur.IsZero() {
|
||||
args = append(args, cur.Ms, cur.ID)
|
||||
conds = append(conds, fmt.Sprintf("(start_at_ms, id) > ($%d, $%d)", len(args)-1, len(args)))
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
q := `SELECT id, name, track_id, max_cars, laps, time_limit_s,
|
||||
start_at_ms, interval_s, count,
|
||||
enabled, created_ms, updated_ms, next_fire_ms, fires_done
|
||||
FROM race_plans
|
||||
WHERE ` + joinAnd(conds) + `
|
||||
ORDER BY start_at_ms ASC, id ASC
|
||||
LIMIT $` + fmt.Sprintf("%d", len(args))
|
||||
rows, err := s.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list plans: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanPlans(rows)
|
||||
}
|
||||
|
||||
func scanPlans(rows pgx.Rows) ([]RacePlan, error) {
|
||||
out := make([]RacePlan, 0)
|
||||
for rows.Next() {
|
||||
var p RacePlan
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.Name, &p.TrackID, &p.MaxCars, &p.Laps, &p.TimeLimitS,
|
||||
&p.StartAtMs, &p.IntervalS, &p.Count,
|
||||
&p.Enabled, &p.CreatedMs, &p.UpdatedMs, &p.NextFireMs, &p.FiresDone,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeletePlan removes a plan by id.
|
||||
func (s *PgStore) DeletePlan(ctx context.Context, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM race_plans WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete plan: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPlanEnabled toggles the enabled flag.
|
||||
func (s *PgStore) SetPlanEnabled(ctx context.Context, id string, enabled bool) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE race_plans SET enabled = $2, updated_ms = $3 WHERE id = $1`,
|
||||
id, enabled, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdvancePlan moves the next_fire_ms cursor after a successful materialise
|
||||
// and increments fires_done. If count is reached, disables the plan.
|
||||
func (s *PgStore) AdvancePlan(ctx context.Context, id string, nextFireMs int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE race_plans
|
||||
SET next_fire_ms = $2,
|
||||
fires_done = fires_done + 1,
|
||||
updated_ms = $3,
|
||||
enabled = CASE
|
||||
WHEN count > 0 AND fires_done + 1 >= count THEN FALSE
|
||||
ELSE enabled
|
||||
END
|
||||
WHERE id = $1`,
|
||||
id, nextFireMs, time.Now().UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
// DuePlans returns plans whose next_fire_ms is <= now.
|
||||
func (s *PgStore) DuePlans(ctx context.Context, nowMs int64, limit int) ([]RacePlan, error) {
|
||||
if limit <= 0 {
|
||||
limit = 16
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, name, track_id, max_cars, laps, time_limit_s,
|
||||
start_at_ms, interval_s, count,
|
||||
enabled, created_ms, updated_ms, next_fire_ms, fires_done
|
||||
FROM race_plans
|
||||
WHERE enabled AND next_fire_ms <= $1
|
||||
ORDER BY next_fire_ms ASC
|
||||
LIMIT $2`, nowMs, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanPlans(rows)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// queue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Enqueue adds (driver, race) to the queue. Idempotent: returns the existing
|
||||
// entry if present.
|
||||
func (s *PgStore) Enqueue(ctx context.Context, driverID, raceID, planID string) (QueueEntry, error) {
|
||||
if driverID == "" || raceID == "" {
|
||||
return QueueEntry{}, fmt.Errorf("%w: driver_id and race_id required", ErrInvalidInput)
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO race_queue (driver_id, race_id, plan_id, enqueued_ms)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (driver_id, race_id) DO NOTHING`,
|
||||
driverID, raceID, planID, now)
|
||||
if err != nil {
|
||||
return QueueEntry{}, fmt.Errorf("enqueue: %w", err)
|
||||
}
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT driver_id, race_id, plan_id, enqueued_ms
|
||||
FROM race_queue WHERE driver_id = $1 AND race_id = $2`,
|
||||
driverID, raceID)
|
||||
var q QueueEntry
|
||||
if err := row.Scan(&q.DriverID, &q.RaceID, &q.PlanID, &q.EnqueuedMs); err != nil {
|
||||
return QueueEntry{}, err
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// Dequeue removes (driver, race) entry.
|
||||
func (s *PgStore) Dequeue(ctx context.Context, driverID, raceID string) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM race_queue WHERE driver_id = $1 AND race_id = $2`,
|
||||
driverID, raceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListQueueByDriver returns queue entries for a driver.
|
||||
func (s *PgStore) ListQueueByDriver(ctx context.Context, driverID string) ([]QueueEntry, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT driver_id, race_id, plan_id, enqueued_ms
|
||||
FROM race_queue WHERE driver_id = $1
|
||||
ORDER BY enqueued_ms ASC`, driverID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]QueueEntry, 0)
|
||||
for rows.Next() {
|
||||
var q QueueEntry
|
||||
if err := rows.Scan(&q.DriverID, &q.RaceID, &q.PlanID, &q.EnqueuedMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListQueueByRace returns all queue entries for a given race, oldest first.
|
||||
func (s *PgStore) ListQueueByRace(ctx context.Context, raceID string) ([]QueueEntry, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT driver_id, race_id, plan_id, enqueued_ms
|
||||
FROM race_queue WHERE race_id = $1
|
||||
ORDER BY enqueued_ms ASC`, raceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]QueueEntry, 0)
|
||||
for rows.Next() {
|
||||
var q QueueEntry
|
||||
if err := rows.Scan(&q.DriverID, &q.RaceID, &q.PlanID, &q.EnqueuedMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountQueueByRace returns the queue length for a race.
|
||||
func (s *PgStore) CountQueueByRace(ctx context.Context, raceID string) (int, error) {
|
||||
var n int
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM race_queue WHERE race_id = $1`, raceID)
|
||||
if err := row.Scan(&n); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ToLobbyMeta converts a finished race to a wire-friendly RaceMeta for
|
||||
// uniform presentation alongside live races. Podium is dropped here;
|
||||
// the wire type carries the full PodiumEntry list separately.
|
||||
func (f FinishedRace) ToLobbyMeta() lobby.RaceMeta {
|
||||
return lobby.RaceMeta{
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
TrackID: f.TrackID,
|
||||
MaxCars: f.MaxCars,
|
||||
Laps: f.Laps,
|
||||
TimeLimitS: f.TimeLimitS,
|
||||
DriverIDs: append([]string(nil), f.DriverIDs...),
|
||||
Status: lobby.RaceStatus(f.Status),
|
||||
CreatedMs: f.CreatedMs,
|
||||
StartedMs: f.StartedMs,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Package races owns the cross-cutting "race list" surface: keyset-paginated
|
||||
// listing of live and finished races, upcoming (next-N) selection, the
|
||||
// driver-to-race queue and the recurring race-plan scheduler.
|
||||
//
|
||||
// The package composes two storage backends:
|
||||
//
|
||||
// - lobby.Service (in-memory): authoritative source for active races
|
||||
// (status = lobby | countdown | racing). Used directly for the live
|
||||
// page of /api/races, for the upcoming view and for the scheduler
|
||||
// materialisation.
|
||||
// - Postgres (pgxpool): authoritative for finished races (snapshotted
|
||||
// on SetRaceStatus(finished)), for race plans and for the queue.
|
||||
// Finished races are read with keyset pagination on
|
||||
// (finished_ms DESC, id DESC).
|
||||
package races
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Errors returned by the service.
|
||||
var (
|
||||
ErrNotFound = errors.New("race or plan not found")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrAlreadyExists = errors.New("already exists")
|
||||
ErrQueueDuplicate = errors.New("driver already in queue for this race")
|
||||
)
|
||||
|
||||
// StatusFilter enumerates the high-level status groups the API understands.
|
||||
type StatusFilter string
|
||||
|
||||
const (
|
||||
StatusAll StatusFilter = "all"
|
||||
StatusFinished StatusFilter = "finished" // finished only
|
||||
StatusRacing StatusFilter = "racing" // racing only
|
||||
StatusLobby StatusFilter = "lobby" // lobby only
|
||||
)
|
||||
|
||||
// ParseStatusFilter accepts a comma-separated list of statuses
|
||||
// (e.g. "racing,finished") and returns the canonical filters. Empty
|
||||
// input means StatusAll.
|
||||
func ParseStatusFilter(s string) ([]StatusFilter, error) {
|
||||
if s == "" {
|
||||
return []StatusFilter{StatusAll}, nil
|
||||
}
|
||||
out := make([]StatusFilter, 0, 2)
|
||||
cur := ""
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c == ',' {
|
||||
tok := statusFilterTrim(cur)
|
||||
if tok == "" {
|
||||
cur = ""
|
||||
continue
|
||||
}
|
||||
f, err := parseSingleStatus(tok)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
cur = ""
|
||||
continue
|
||||
}
|
||||
cur += string(c)
|
||||
}
|
||||
tok := statusFilterTrim(cur)
|
||||
if tok != "" {
|
||||
f, err := parseSingleStatus(tok)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []StatusFilter{StatusAll}, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func statusFilterTrim(s string) string {
|
||||
// Trim spaces.
|
||||
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
|
||||
s = s[1:]
|
||||
}
|
||||
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseSingleStatus(s string) (StatusFilter, error) {
|
||||
switch s {
|
||||
case "all":
|
||||
return StatusAll, nil
|
||||
case "finished":
|
||||
return StatusFinished, nil
|
||||
case "racing":
|
||||
return StatusRacing, nil
|
||||
case "lobby":
|
||||
return StatusLobby, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: unknown status %q", ErrInvalidInput, s)
|
||||
}
|
||||
}
|
||||
|
||||
// matches returns true if the live lobby status passes the filter.
|
||||
func (f StatusFilter) matchesLive(liveStatus string) bool {
|
||||
switch f {
|
||||
case StatusAll:
|
||||
return true
|
||||
case StatusRacing:
|
||||
return liveStatus == "racing"
|
||||
case StatusLobby:
|
||||
return liveStatus == "lobby"
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Package realtime manages WebSocket connections: registration, fan-out of
|
||||
// race snapshots, and per-client send queues with backpressure handling.
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/x0gp/server/internal/transport"
|
||||
)
|
||||
|
||||
// Client is a single connected WebSocket client.
|
||||
type Client struct {
|
||||
ID string
|
||||
Send chan []byte
|
||||
SessionID string
|
||||
Done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// Close signals the read/write pumps to exit and frees the send channel.
|
||||
func (c *Client) Close() {
|
||||
c.once.Do(func() { close(c.Done) })
|
||||
}
|
||||
|
||||
// Hub multiplexes snapshot broadcast to many clients.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]struct{}
|
||||
register chan *Client
|
||||
unregister chan *Client
|
||||
broadcast chan []byte
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
|
||||
// Stats.
|
||||
connTotal atomic.Uint64
|
||||
dropTotal atomic.Uint64
|
||||
}
|
||||
|
||||
// NewHub creates a hub with sane defaults (send-buffer 64 frames).
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[*Client]struct{}),
|
||||
register: make(chan *Client, 16),
|
||||
unregister: make(chan *Client, 16),
|
||||
broadcast: make(chan []byte, 64),
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Stats returns current hub statistics.
|
||||
type Stats struct {
|
||||
Connections uint64
|
||||
DropsTotal uint64
|
||||
}
|
||||
|
||||
func (h *Hub) Stats() Stats {
|
||||
h.mu.RLock()
|
||||
n := uint64(len(h.clients))
|
||||
h.mu.RUnlock()
|
||||
return Stats{Connections: n, DropsTotal: h.dropTotal.Load()}
|
||||
}
|
||||
|
||||
// Register adds a client to the hub.
|
||||
func (h *Hub) Register(c *Client) { h.register <- c }
|
||||
|
||||
// Unregister removes a client from the hub.
|
||||
func (h *Hub) Unregister(c *Client) {
|
||||
select {
|
||||
case h.unregister <- c:
|
||||
default:
|
||||
// Channel full; force-close anyway.
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Run drives the hub until ctx is cancelled or Stop() is called.
|
||||
func (h *Hub) Run(ctx context.Context) {
|
||||
defer close(h.doneCh)
|
||||
|
||||
// Snapshot fan-out loop.
|
||||
go h.snapshotFanout(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-h.stopCh:
|
||||
return
|
||||
case c := <-h.register:
|
||||
h.mu.Lock()
|
||||
h.clients[c] = struct{}{}
|
||||
h.connTotal.Add(1)
|
||||
h.mu.Unlock()
|
||||
case c := <-h.unregister:
|
||||
h.mu.Lock()
|
||||
if _, ok := h.clients[c]; ok {
|
||||
delete(h.clients, c)
|
||||
close(c.Send)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop terminates the hub.
|
||||
func (h *Hub) Stop() {
|
||||
close(h.stopCh)
|
||||
<-h.doneCh
|
||||
}
|
||||
|
||||
// snapshotFanout consumes from the broadcast channel and sends to clients,
|
||||
// dropping frames to slow consumers.
|
||||
func (h *Hub) snapshotFanout(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-h.stopCh:
|
||||
return
|
||||
case frame, ok := <-h.broadcast:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
h.fanout(frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fanout delivers a frame to every client (non-blocking per-client).
|
||||
func (h *Hub) fanout(frame []byte) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.clients {
|
||||
select {
|
||||
case c.Send <- frame:
|
||||
default:
|
||||
// Slow consumer; drop and count.
|
||||
h.dropTotal.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publish enqueues an envelope to be broadcast to all clients.
|
||||
func (h *Hub) Publish(env *transport.Envelope) {
|
||||
data, err := transport.Encode(env)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case h.broadcast <- data:
|
||||
default:
|
||||
h.dropTotal.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// helper to construct a server -> client envelope.
|
||||
func MustEnvelope(t transport.MessageType, payload any) *transport.Envelope {
|
||||
return &transport.Envelope{
|
||||
Type: t,
|
||||
TSMs: time.Now().UnixMilli(),
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConnectDisconnectCounts(t *testing.T) {
|
||||
c := NewCollector()
|
||||
c.Start()
|
||||
c.OnConnect("d1", "Alice")
|
||||
c.OnConnect("d2", "Bob")
|
||||
c.OnDisconnect("d1", 5000)
|
||||
|
||||
s := c.Server()
|
||||
if s.CurrentClients != 1 {
|
||||
t.Errorf("CurrentClients: got %d", s.CurrentClients)
|
||||
}
|
||||
if s.PeakClients != 2 {
|
||||
t.Errorf("PeakClients: got %d", s.PeakClients)
|
||||
}
|
||||
if s.TotalConnections != 2 {
|
||||
t.Errorf("TotalConnections: got %d", s.TotalConnections)
|
||||
}
|
||||
if s.TotalDisconnects != 1 {
|
||||
t.Errorf("TotalDisconnects: got %d", s.TotalDisconnects)
|
||||
}
|
||||
if s.UptimeMs < 0 {
|
||||
t.Errorf("UptimeMs negative: %d", s.UptimeMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLapAndDriverProfile(t *testing.T) {
|
||||
c := NewCollector()
|
||||
c.Start()
|
||||
c.OnConnect("d1", "Alice")
|
||||
c.OnLapRecorded(LapRecord{RaceID: "r1", DriverID: "d1", LapMs: 12_345, TSMs: time.Now().UnixMilli()})
|
||||
c.OnLapRecorded(LapRecord{RaceID: "r1", DriverID: "d1", LapMs: 11_500, TSMs: time.Now().UnixMilli()})
|
||||
c.OnLapRecorded(LapRecord{RaceID: "r1", DriverID: "d1", LapMs: 13_000, TSMs: time.Now().UnixMilli()})
|
||||
|
||||
d := c.Driver("d1", "Alice")
|
||||
if d.TotalLaps != 3 {
|
||||
t.Errorf("TotalLaps: got %d", d.TotalLaps)
|
||||
}
|
||||
if d.BestLapMs != 11_500 {
|
||||
t.Errorf("BestLapMs: got %d", d.BestLapMs)
|
||||
}
|
||||
if d.LastLapMs != 13_000 {
|
||||
t.Errorf("LastLapMs: got %d", d.LastLapMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaceFinishedUpdatesProfile(t *testing.T) {
|
||||
c := NewCollector()
|
||||
c.Start()
|
||||
c.OnConnect("d1", "Alice")
|
||||
c.OnConnect("d2", "Bob")
|
||||
c.OnRaceStarted([]string{"d1", "d2"})
|
||||
|
||||
c.OnRaceFinished(RaceResult{RaceID: "r1", DriverID: "d1", Position: 1, TotalLaps: 5, BestLapMs: 11_000, TotalTimeMs: 60_000})
|
||||
c.OnRaceFinished(RaceResult{RaceID: "r1", DriverID: "d2", Position: 2, TotalLaps: 5, BestLapMs: 11_500, TotalTimeMs: 60_500})
|
||||
|
||||
d1 := c.Driver("d1", "")
|
||||
if d1.Wins != 1 {
|
||||
t.Errorf("Wins: got %d", d1.Wins)
|
||||
}
|
||||
if d1.BestLapMs != 11_000 {
|
||||
t.Errorf("BestLapMs: got %d", d1.BestLapMs)
|
||||
}
|
||||
d2 := c.Driver("d2", "")
|
||||
if d2.Wins != 0 || d2.Podiums != 1 {
|
||||
t.Errorf("Podiums: got %d", d2.Podiums)
|
||||
}
|
||||
|
||||
s := c.Server()
|
||||
if s.TotalRacesStarted != 1 || s.TotalRacesFinished != 2 {
|
||||
t.Errorf("counters: started=%d finished=%d", s.TotalRacesStarted, s.TotalRacesFinished)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRTTWindowAverage(t *testing.T) {
|
||||
c := NewCollector()
|
||||
c.Start()
|
||||
for _, v := range []int64{10, 20, 30, 40, 50} {
|
||||
c.OnRTT(v)
|
||||
}
|
||||
s := c.Server()
|
||||
if s.AvgRTTMs != 30 {
|
||||
t.Errorf("AvgRTTMs: got %v want 30", s.AvgRTTMs)
|
||||
}
|
||||
if len(s.RecentRTTs) != 5 {
|
||||
t.Errorf("RecentRTTs len: got %d", len(s.RecentRTTs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentLapsOrder(t *testing.T) {
|
||||
c := NewCollector()
|
||||
c.Start()
|
||||
for i := 0; i < 5; i++ {
|
||||
c.OnLapRecorded(LapRecord{RaceID: "r1", DriverID: "d1", LapMs: int64(10_000 + i), TSMs: int64(i)})
|
||||
}
|
||||
laps := c.RecentLaps(3)
|
||||
if len(laps) != 3 {
|
||||
t.Fatalf("got %d laps", len(laps))
|
||||
}
|
||||
if laps[0].TSMs < laps[1].TSMs {
|
||||
t.Errorf("expected newest first, got order: %v", laps)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// Package stats collects and exposes server and per-driver metrics.
|
||||
//
|
||||
// PoC: in-memory only, atomic counters where possible, mutex around
|
||||
// driver and race records. The data lives for the lifetime of the
|
||||
// process. A future storage layer will swap this for Postgres/Redis.
|
||||
//
|
||||
// Public surface is split into:
|
||||
// - lifecycle hooks (Connect/Disconnect/Race*/Lap*/Input*/RTT) —
|
||||
// called from cmd/poc-server and from internal/control and lobby
|
||||
// - Snapshot() — full server snapshot for /stats/detailed
|
||||
// - DriverProfile(id) — per-driver summary
|
||||
// - RecentLaps(n) — last N laps across all races
|
||||
package stats
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ServerStats is the top-level metrics snapshot.
|
||||
type ServerStats struct {
|
||||
StartedMs int64 `json:"started_ms"`
|
||||
UptimeMs int64 `json:"uptime_ms"`
|
||||
CurrentClients int64 `json:"current_clients"`
|
||||
PeakClients int64 `json:"peak_clients"`
|
||||
TotalConnections uint64 `json:"total_connections"`
|
||||
TotalDisconnects uint64 `json:"total_disconnects"`
|
||||
TotalRacesCreated uint64 `json:"total_races_created"`
|
||||
TotalRacesStarted uint64 `json:"total_races_started"`
|
||||
TotalRacesFinished uint64 `json:"total_races_finished"`
|
||||
TotalLapsRecorded uint64 `json:"total_laps_recorded"`
|
||||
TotalInputsRecv uint64 `json:"total_inputs_received"`
|
||||
TotalSnapshotsSent uint64 `json:"total_snapshots_sent"`
|
||||
SnapshotsDropped uint64 `json:"snapshots_dropped"`
|
||||
AvgRTTMs float64 `json:"avg_rtt_ms"`
|
||||
RecentRTTs []int64 `json:"recent_rtt_ms"` // windowed samples
|
||||
}
|
||||
|
||||
// DriverProfile aggregates per-driver statistics.
|
||||
type DriverProfile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TotalRacesStarted uint64 `json:"total_races_started"`
|
||||
TotalRacesFinished uint64 `json:"total_races_finished"`
|
||||
TotalLaps uint64 `json:"total_laps"`
|
||||
BestLapMs int64 `json:"best_lap_ms"`
|
||||
LastLapMs int64 `json:"last_lap_ms"`
|
||||
TotalDistanceM float64 `json:"total_distance_m"`
|
||||
TotalPlaytimeMs int64 `json:"total_playtime_ms"`
|
||||
Wins uint64 `json:"wins"`
|
||||
Podiums uint64 `json:"podiums"`
|
||||
LastSeenMs int64 `json:"last_seen_ms"`
|
||||
}
|
||||
|
||||
// LapRecord is one lap, kept for /stats/recent.
|
||||
type LapRecord struct {
|
||||
RaceID string `json:"race_id"`
|
||||
DriverID string `json:"driver_id"`
|
||||
LapMs int64 `json:"lap_ms"`
|
||||
Sector1Ms int64 `json:"sector1_ms"`
|
||||
Sector2Ms int64 `json:"sector2_ms"`
|
||||
Sector3Ms int64 `json:"sector3_ms"`
|
||||
Position int `json:"position,omitempty"`
|
||||
TSMs int64 `json:"ts_ms"`
|
||||
}
|
||||
|
||||
// RaceResult is the final classification of a race for one driver.
|
||||
type RaceResult struct {
|
||||
RaceID string `json:"race_id"`
|
||||
DriverID string `json:"driver_id"`
|
||||
FinishedAtMs int64 `json:"finished_at_ms"`
|
||||
Position int `json:"position"`
|
||||
TotalLaps int `json:"total_laps"`
|
||||
BestLapMs int64 `json:"best_lap_ms"`
|
||||
TotalTimeMs int64 `json:"total_time_ms"`
|
||||
DNF bool `json:"dnf"`
|
||||
}
|
||||
|
||||
// collector owns the runtime state.
|
||||
type collector struct {
|
||||
startedMs atomic.Int64
|
||||
|
||||
currentClients atomic.Int64
|
||||
peakClients atomic.Int64
|
||||
|
||||
totalConn atomic.Uint64
|
||||
totalDisc atomic.Uint64
|
||||
totalRacesC atomic.Uint64
|
||||
totalRacesS atomic.Uint64
|
||||
totalRacesF atomic.Uint64
|
||||
totalLaps atomic.Uint64
|
||||
totalInputs atomic.Uint64
|
||||
totalSnapsSent atomic.Uint64
|
||||
totalSnapDrops atomic.Uint64
|
||||
|
||||
// RTT moving window (last 32 samples).
|
||||
rttMu sync.Mutex
|
||||
rttWin []int64
|
||||
}
|
||||
|
||||
// Collector is the public stats service.
|
||||
type Collector struct {
|
||||
c collector
|
||||
|
||||
mu sync.RWMutex
|
||||
drivers map[string]*DriverProfile
|
||||
laps []LapRecord // ring buffer
|
||||
lapsHead int
|
||||
lapsCap int
|
||||
|
||||
results []RaceResult
|
||||
resultsMax int
|
||||
}
|
||||
|
||||
// NewCollector returns a fresh collector.
|
||||
func NewCollector() *Collector {
|
||||
return &Collector{
|
||||
c: collector{},
|
||||
drivers: make(map[string]*DriverProfile),
|
||||
lapsCap: 200,
|
||||
laps: make([]LapRecord, 200),
|
||||
resultsMax: 500,
|
||||
results: make([]RaceResult, 0, 500),
|
||||
}
|
||||
}
|
||||
|
||||
// Start marks process start time. Call once.
|
||||
func (c *Collector) Start() {
|
||||
c.c.startedMs.Store(time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
// Lifecycle hooks --------------------------------------------------------
|
||||
|
||||
// OnConnect increments counters and starts a session.
|
||||
func (c *Collector) OnConnect(clientID, name string) {
|
||||
c.c.totalConn.Add(1)
|
||||
cur := c.c.currentClients.Add(1)
|
||||
for {
|
||||
peak := c.c.peakClients.Load()
|
||||
if cur <= peak || c.c.peakClients.CompareAndSwap(peak, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.touchDriver(clientID, name)
|
||||
}
|
||||
|
||||
// OnDisconnect decrements current and adds session duration.
|
||||
func (c *Collector) OnDisconnect(clientID string, sessionMs int64) {
|
||||
c.c.currentClients.Add(-1)
|
||||
c.c.totalDisc.Add(1)
|
||||
c.mu.Lock()
|
||||
if d, ok := c.drivers[clientID]; ok {
|
||||
d.TotalPlaytimeMs += sessionMs
|
||||
d.LastSeenMs = time.Now().UnixMilli()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// OnRaceCreated increments a counter.
|
||||
func (c *Collector) OnRaceCreated() { c.c.totalRacesC.Add(1) }
|
||||
|
||||
// OnRaceStarted increments a counter and bumps driver race count.
|
||||
func (c *Collector) OnRaceStarted(driverIDs []string) {
|
||||
c.c.totalRacesS.Add(1)
|
||||
c.mu.Lock()
|
||||
for _, id := range driverIDs {
|
||||
if d, ok := c.drivers[id]; ok {
|
||||
d.TotalRacesStarted++
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// OnRaceFinished records final classification. Position is 1-based.
|
||||
func (c *Collector) OnRaceFinished(result RaceResult) {
|
||||
c.c.totalRacesF.Add(1)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if d, ok := c.drivers[result.DriverID]; ok {
|
||||
if !result.DNF {
|
||||
d.TotalRacesFinished++
|
||||
}
|
||||
if result.Position == 1 {
|
||||
d.Wins++
|
||||
} else if result.Position >= 1 && result.Position <= 3 {
|
||||
d.Podiums++
|
||||
}
|
||||
if result.BestLapMs > 0 && (d.BestLapMs == 0 || result.BestLapMs < d.BestLapMs) {
|
||||
d.BestLapMs = result.BestLapMs
|
||||
}
|
||||
}
|
||||
c.results = append(c.results, result)
|
||||
if len(c.results) > c.resultsMax {
|
||||
// drop oldest 10%
|
||||
drop := c.resultsMax / 10
|
||||
c.results = c.results[drop:]
|
||||
}
|
||||
}
|
||||
|
||||
// OnLapRecorded stores lap in ring buffer and updates driver totals.
|
||||
func (c *Collector) OnLapRecorded(rec LapRecord) {
|
||||
c.c.totalLaps.Add(1)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if d, ok := c.drivers[rec.DriverID]; ok {
|
||||
d.TotalLaps++
|
||||
d.LastLapMs = rec.LapMs
|
||||
if rec.LapMs > 0 && (d.BestLapMs == 0 || rec.LapMs < d.BestLapMs) {
|
||||
d.BestLapMs = rec.LapMs
|
||||
}
|
||||
}
|
||||
c.laps[c.lapsHead] = rec
|
||||
c.lapsHead = (c.lapsHead + 1) % c.lapsCap
|
||||
}
|
||||
|
||||
// OnInputProcessed increments input counter.
|
||||
func (c *Collector) OnInputProcessed() { c.c.totalInputs.Add(1) }
|
||||
|
||||
// OnSnapshotSent increments snapshot counter and the drop counter.
|
||||
func (c *Collector) OnSnapshotSent(sent, dropped uint64) {
|
||||
c.c.totalSnapsSent.Add(sent)
|
||||
c.c.totalSnapDrops.Add(dropped)
|
||||
}
|
||||
|
||||
// OnRTT adds a sample to the moving window.
|
||||
func (c *Collector) OnRTT(rttMs int64) {
|
||||
if rttMs < 0 {
|
||||
return
|
||||
}
|
||||
c.c.rttMu.Lock()
|
||||
defer c.c.rttMu.Unlock()
|
||||
c.c.rttWin = append(c.c.rttWin, rttMs)
|
||||
if len(c.c.rttWin) > 32 {
|
||||
c.c.rttWin = c.c.rttWin[len(c.c.rttWin)-32:]
|
||||
}
|
||||
}
|
||||
|
||||
// OnDistance accumulates meters driven (called per snapshot).
|
||||
func (c *Collector) OnDistance(driverID string, meters float64) {
|
||||
if meters <= 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
if d, ok := c.drivers[driverID]; ok {
|
||||
d.TotalDistanceM += meters
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Snapshots ---------------------------------------------------------------
|
||||
|
||||
// Server returns the current server-level metrics.
|
||||
func (c *Collector) Server() ServerStats {
|
||||
now := time.Now().UnixMilli()
|
||||
c.c.rttMu.Lock()
|
||||
window := append([]int64(nil), c.c.rttWin...)
|
||||
c.c.rttMu.Unlock()
|
||||
|
||||
var avg float64
|
||||
if len(window) > 0 {
|
||||
var sum int64
|
||||
for _, v := range window {
|
||||
sum += v
|
||||
}
|
||||
avg = float64(sum) / float64(len(window))
|
||||
}
|
||||
|
||||
return ServerStats{
|
||||
StartedMs: c.c.startedMs.Load(),
|
||||
UptimeMs: now - c.c.startedMs.Load(),
|
||||
CurrentClients: c.c.currentClients.Load(),
|
||||
PeakClients: c.c.peakClients.Load(),
|
||||
TotalConnections: c.c.totalConn.Load(),
|
||||
TotalDisconnects: c.c.totalDisc.Load(),
|
||||
TotalRacesCreated: c.c.totalRacesC.Load(),
|
||||
TotalRacesStarted: c.c.totalRacesS.Load(),
|
||||
TotalRacesFinished: c.c.totalRacesF.Load(),
|
||||
TotalLapsRecorded: c.c.totalLaps.Load(),
|
||||
TotalInputsRecv: c.c.totalInputs.Load(),
|
||||
TotalSnapshotsSent: c.c.totalSnapsSent.Load(),
|
||||
SnapshotsDropped: c.c.totalSnapDrops.Load(),
|
||||
AvgRTTMs: avg,
|
||||
RecentRTTs: window,
|
||||
}
|
||||
}
|
||||
|
||||
// Driver returns the profile for one driver, creating it if missing.
|
||||
func (c *Collector) Driver(id, name string) DriverProfile {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
d, ok := c.drivers[id]
|
||||
if !ok {
|
||||
d = &DriverProfile{ID: id, Name: name}
|
||||
c.drivers[id] = d
|
||||
} else if name != "" {
|
||||
d.Name = name
|
||||
}
|
||||
return *d
|
||||
}
|
||||
|
||||
// Drivers returns a copy of all driver profiles.
|
||||
func (c *Collector) Drivers() []DriverProfile {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := make([]DriverProfile, 0, len(c.drivers))
|
||||
for _, d := range c.drivers {
|
||||
out = append(out, *d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RecentLaps returns the last N lap records (newest first).
|
||||
func (c *Collector) RecentLaps(n int) []LapRecord {
|
||||
if n <= 0 || n > c.lapsCap {
|
||||
n = c.lapsCap
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := make([]LapRecord, 0, n)
|
||||
// Walk backwards from lapsHead.
|
||||
for i := 0; i < c.lapsCap && len(out) < n; i++ {
|
||||
idx := (c.lapsHead - 1 - i + c.lapsCap) % c.lapsCap
|
||||
if c.laps[idx].TSMs == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, c.laps[idx])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RecentResults returns the last N race results.
|
||||
func (c *Collector) RecentResults(n int) []RaceResult {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if n <= 0 || n > len(c.results) {
|
||||
n = len(c.results)
|
||||
}
|
||||
out := make([]RaceResult, n)
|
||||
copy(out, c.results[len(c.results)-n:])
|
||||
// reverse for newest-first
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// helpers ----------------------------------------------------------------
|
||||
|
||||
// touchDriver creates a profile if missing and updates LastSeenMs.
|
||||
func (c *Collector) touchDriver(id, name string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
d, ok := c.drivers[id]
|
||||
if !ok {
|
||||
c.drivers[id] = &DriverProfile{ID: id, Name: name, LastSeenMs: time.Now().UnixMilli()}
|
||||
return
|
||||
}
|
||||
d.LastSeenMs = time.Now().UnixMilli()
|
||||
if name != "" && d.Name == "" {
|
||||
d.Name = name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
-- 001_init.sql — initial schema for x0gp catalog.
|
||||
--
|
||||
-- Tables:
|
||||
-- tracks static track definitions (oval, figure-eight, etc.)
|
||||
-- track_waypoints racing line samples (1-to-many with tracks)
|
||||
-- track_tags many-to-many tags for tracks
|
||||
-- cars car definitions (chassis, motor, battery, etc.)
|
||||
--
|
||||
-- TimescaleDB extension is enabled opportunistically — not required for the
|
||||
-- catalog itself; the telemetry hypertable will be added in a later phase
|
||||
-- (S2). We enable it here so that the extension is provisioned alongside
|
||||
-- the base schema.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tracks
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tracks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
author_id TEXT NOT NULL DEFAULT 'system',
|
||||
visibility TEXT NOT NULL DEFAULT 'system'
|
||||
CHECK (visibility IN ('system', 'public', 'private')),
|
||||
scale INT NOT NULL DEFAULT 27,
|
||||
length_m DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
width_m DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
lane_width_m DOUBLE PRECISION NOT NULL DEFAULT 0.20,
|
||||
surface TEXT NOT NULL DEFAULT 'carpet'
|
||||
CHECK (surface IN ('carpet', 'tile', 'wood', 'paper', 'mixed')),
|
||||
best_lap_ms BIGINT NOT NULL DEFAULT 0,
|
||||
best_lap_holder TEXT NOT NULL DEFAULT '',
|
||||
bounds_min_x DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
bounds_min_y DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
bounds_max_x DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
bounds_max_y DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
created_ms BIGINT NOT NULL,
|
||||
updated_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tracks_visibility_idx ON tracks (visibility);
|
||||
CREATE INDEX IF NOT EXISTS tracks_author_idx ON tracks (author_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS track_waypoints (
|
||||
track_id TEXT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
seq INT NOT NULL,
|
||||
x DOUBLE PRECISION NOT NULL,
|
||||
y DOUBLE PRECISION NOT NULL,
|
||||
heading_rad DOUBLE PRECISION NOT NULL,
|
||||
speed_ms DOUBLE PRECISION NOT NULL,
|
||||
curvature DOUBLE PRECISION NOT NULL,
|
||||
PRIMARY KEY (track_id, seq)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS track_tags (
|
||||
track_id TEXT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (track_id, tag)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS track_tags_tag_idx ON track_tags (tag);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Cars
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cars (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL DEFAULT 'system',
|
||||
visibility TEXT NOT NULL DEFAULT 'system'
|
||||
CHECK (visibility IN ('system', 'public', 'private')),
|
||||
scale INT NOT NULL DEFAULT 27,
|
||||
|
||||
length_mm DOUBLE PRECISION NOT NULL,
|
||||
width_mm DOUBLE PRECISION NOT NULL,
|
||||
height_mm DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
weight_g DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
|
||||
chassis_model TEXT NOT NULL DEFAULT '',
|
||||
chassis_material TEXT NOT NULL DEFAULT '',
|
||||
chassis_printed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
chassis_wheelbase_mm DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
chassis_track_mm DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
|
||||
motor_kind TEXT NOT NULL DEFAULT 'brushed',
|
||||
motor_class TEXT NOT NULL DEFAULT '',
|
||||
motor_kv INT NOT NULL DEFAULT 0,
|
||||
motor_power_w DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
|
||||
battery_voltage_v DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
battery_capacity_mah INT NOT NULL DEFAULT 0,
|
||||
battery_cells INT NOT NULL DEFAULT 0,
|
||||
battery_chemistry TEXT NOT NULL DEFAULT 'li-po',
|
||||
|
||||
drive TEXT NOT NULL DEFAULT '2WD'
|
||||
CHECK (drive IN ('2WD', '4WD')),
|
||||
|
||||
top_speed_ms DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
color_hex TEXT NOT NULL DEFAULT '#ff6b1a',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
total_distance_m DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
total_races INT NOT NULL DEFAULT 0,
|
||||
total_laps INT NOT NULL DEFAULT 0,
|
||||
best_lap_ms BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
created_ms BIGINT NOT NULL,
|
||||
updated_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cars_visibility_idx ON cars (visibility);
|
||||
CREATE INDEX IF NOT EXISTS cars_owner_idx ON cars (owner_id);
|
||||
@@ -0,0 +1,561 @@
|
||||
-- 002_seed.sql — initial system catalog (tracks + cars).
|
||||
-- Generated from internal/catalog/seeddefs via scripts/genseed.
|
||||
-- Idempotent: safe to re-apply (ON CONFLICT DO NOTHING).
|
||||
|
||||
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('monaco', 'Monaco', 'Circuit de Monaco. Narrow street circuit, tight hairpins, no room for error.', 'system', 'system', 27,
|
||||
3.5500000000000003, 1.1, 0.18, 'tile', 0, 'Verstappen',
|
||||
0.4, 1.4, 3.95, 2.5,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 0, 0.4, 2.2, 0, 3.2, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 1, 1.1, 2.2, 0, 3, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 2, 1.5, 2.05, -0.4, 2.4, 1.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 3, 1.7, 1.85, -0.9, 2.2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 4, 1.55, 1.55, -1.6, 2, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 5, 1.25, 1.4, -2.4, 2.1, 1.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 6, 0.95, 1.55, 3.141592653589793, 1.9, 1.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 7, 0.75, 1.85, 2, 1.8, 2.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 8, 0.95, 2.15, 2.6, 1.8, 2.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 9, 1.2, 2.4, -2.8, 2.1, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 10, 1.6, 2.5, -3, 2.4, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 11, 2.6, 2.5, 0, 3.2, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 12, 3.4, 2.5, 0, 3.2, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 13, 3.6, 2.25, -0.5, 2.6, 1.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 14, 3.55, 1.95, 0.4, 2.6, 1.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 15, 3.3, 1.75, 1, 2.3, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 16, 2.95, 1.55, 1.7, 2.1, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 17, 2.6, 1.65, 2.6, 2.1, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 18, 2.4, 1.9, -2.7, 2.3, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 19, 2.4, 2.2, -3, 2.2, 1.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 20, 2.6, 2.4, 2.8, 2.5, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 21, 3.2, 2.4, 0, 3.2, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 22, 3.7, 2.4, 0, 3.2, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 23, 3.95, 2.3, -0.3, 2.8, 0.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'f1')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'street')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'tight')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'slow')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'monaco')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('barcelona-catalunya', 'Barcelona-Catalunya', 'Circuit de Barcelona-Catalunya. Mix of high-speed corners and technical chicanes.', 'system', 'system', 27,
|
||||
3.75, 2.3000000000000003, 0.18, 'tile', 0, 'Verstappen',
|
||||
0.3, 0.4, 4.05, 2.7,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 0, 0.3, 0.4, 0, 4.5, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 1, 1.4, 0.4, 0, 4.5, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 2, 1.7, 0.65, 1.5707963267948966, 2.2, 1.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 3, 1.5, 0.95, 3.141592653589793, 2, 2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 4, 1.2, 1.1, 3.4415926535897934, 2.6, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 5, 0.95, 1, 4.0415926535897935, 2.6, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 6, 0.8, 1.2, -1.5707963267948966, 2.4, 1.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 7, 1.05, 1.5, 0, 3.2, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 8, 1.3, 1.65, 1.5707963267948966, 2.4, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 9, 1.15, 1.85, 3.141592653589793, 2.4, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 10, 1.4, 2, 2.641592653589793, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 11, 1.7, 2, 2.141592653589793, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 12, 1.95, 2.2, 1.5707963267948966, 2.8, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 13, 1.65, 2.5, 0.3, 2.8, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 14, 2.2, 2.7, 0.7, 3.2, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 15, 2.8, 2.7, 1.1, 3.4, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 16, 3.1, 2.5, 1.9707963267948967, 2.2, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 17, 2.95, 2.2, 3.541592653589793, 2.2, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 18, 3.3, 1.9, 0, 4, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 19, 4, 1.9, 0, 4, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 20, 4.05, 1.6, -1.5707963267948966, 2.4, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 21, 3.8, 1.4, 3.141592653589793, 2.4, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 22, 3.5, 1.5, 3.9269908169872414, 2.6, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 23, 3.1, 1.25, -1.9707963267948967, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 24, 2.7, 1, -2.4707963267948965, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 25, 2.3, 0.7, 3.541592653589793, 3.4, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 26, 1.6, 0.5, 3.3415926535897933, 3.6, 0.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 27, 0.9, 0.42, 0, 4, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('barcelona-catalunya', 'f1')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('barcelona-catalunya', 'balanced')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('barcelona-catalunya', 'testing')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('barcelona-catalunya', 'barcelona')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('austria-redbull-ring', 'Austria (Red Bull Ring)', 'Red Bull Ring. Short, fast, three straights separated by hairpins.', 'system', 'system', 27,
|
||||
3.5500000000000003, 2, 0.18, 'wood', 0, 'Verstappen',
|
||||
0.3, 0.4, 3.85, 2.4,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 0, 0.3, 0.4, 0, 4.8, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 1, 1.6, 0.4, 0, 4.8, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 2, 1.85, 0.65, 1.5707963267948966, 2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 3, 1.6, 0.95, 3.141592653589793, 2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 4, 1.3, 1, 3.641592653589793, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 5, 1.05, 1.2, -1.5707963267948966, 2.6, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 6, 1.4, 1.4, 0, 3.2, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 7, 1.85, 1.4, 1.7707963267948965, 3.2, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 8, 2.1, 1.65, 3.141592653589793, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 9, 2, 1.85, -1.5707963267948966, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 10, 2.25, 2.05, 0, 2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 11, 2.4, 2, 1.5707963267948966, 2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 12, 2.4, 2.2, 3.141592653589793, 2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 13, 2.2, 2.3, -1.5707963267948966, 2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 14, 2.55, 2.4, -0.2, 3.4, 0.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 15, 3, 2.4, 0, 4, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 16, 3.5, 2.4, 0, 4.5, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 17, 3.85, 2.15, 1.7707963267948965, 3.4, 0.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 18, 3.6, 1.8, 3.141592653589793, 3, 0.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 19, 3.25, 1.85, 3.741592653589793, 3, 0.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 20, 3, 1.6, -1.9707963267948967, 3.2, 0.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 21, 2.6, 1.4, -3.141592653589793, 3.4, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 22, 2, 1.2, 2.641592653589793, 3.6, 0.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 23, 1.4, 0.8, 2.9415926535897934, 3.8, 0.3)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 24, 0.8, 0.5, 0, 4.2, 0.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('austria-redbull-ring', 'f1')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('austria-redbull-ring', 'short')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('austria-redbull-ring', 'fast')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('austria-redbull-ring', 'austria')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('great-britain-silverstone', 'Great Britain (Silverstone)', 'Silverstone Circuit. Fast, flowing corners; home of British motorsport.', 'system', 'system', 27,
|
||||
3.5500000000000003, 1.2499999999999998, 0.18, 'carpet', 0, 'Hamilton',
|
||||
0.3, 1.8, 3.85, 3.05,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 0, 0.3, 2.8, 0, 5, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 1, 2.4, 2.8, 0, 5, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 2, 2.8, 2.65, -1.5707963267948966, 3, 0.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 3, 2.6, 2.3, -3.141592653589793, 3.4, 0.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 4, 2.95, 2.05, -2.0707963267948966, 3.2, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 5, 3.3, 1.8, 0, 2.2, 1.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 6, 3.6, 1.95, 1.5707963267948966, 2.2, 1.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 7, 3.6, 2.3, 3.141592653589793, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 8, 3.85, 2.55, 1.5707963267948966, 2.6, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 9, 3.55, 2.85, 0, 2.4, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 10, 3.25, 2.85, 1.5707963267948966, 2.4, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 11, 3.05, 2.55, -1.8707963267948966, 3.4, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 12, 2.85, 2.65, -3.141592653589793, 4, 0.3)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 13, 1.8, 2.8, 0, 4.6, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 14, 1.2, 2.95, 1.7707963267948965, 3.8, 0.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 15, 1, 2.65, 3.541592653589793, 3.6, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 16, 0.7, 2.4, -1.8707963267948966, 2.4, 1.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 17, 0.5, 2.6, 0, 2.6, 0.9)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 18, 0.7, 2.85, 1.5707963267948966, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 19, 0.45, 3.05, 3.541592653589793, 2.6, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 20, 0.7, 2.5, -1.1707963267948966, 2.4, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 21, 0.5, 2.05, 3.541592653589793, 2.6, 0.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 22, 0.3, 2.4, 1.5707963267948966, 2.4, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('great-britain-silverstone', 'f1')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('great-britain-silverstone', 'fast')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('great-britain-silverstone', 'flowing')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('great-britain-silverstone', 'silverstone')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('belgium-spa', 'Belgium (Spa-Francorchamps)', 'Spa-Francorchamps. Long, fast, legendary Eau Rouge / Raidillon.', 'system', 'system', 27,
|
||||
3.85, 2.25, 0.18, 'carpet', 0, 'Verstappen',
|
||||
0.4, 0.5, 4.25, 2.75,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 0, 0.4, 0.5, 0, 4.8, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 1, 1.6, 0.5, 0, 4.8, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 2, 1.95, 0.6, 0.3, 4.2, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 3, 2.2, 0.95, 0.9, 4, 0.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 4, 2.1, 1.3, 3.541592653589793, 4, 0.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 5, 2.7, 1.5, 0, 5, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 6, 3.5, 1.5, 0, 5, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 7, 3.85, 1.3, -1.5707963267948966, 2.4, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 8, 3.6, 1.1, 3.4415926535897934, 2.6, 1.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 9, 3.3, 1.25, 4.0415926535897935, 2.6, 1.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 10, 3.5, 1.55, 1.5707963267948966, 3.6, 0.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 11, 3.25, 1.85, 0, 2.2, 1.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 12, 3.05, 1.7, -1.5707963267948966, 2.4, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 13, 3.25, 1.5, 0, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 14, 3.7, 1.65, 1.8707963267948966, 3.4, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 15, 4.1, 2, 2.1707963267948966, 4, 0.3)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 16, 4.25, 2.4, 3.541592653589793, 4.6, 0.3)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 17, 4, 2.7, -1.8707963267948966, 2.4, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 18, 3.7, 2.55, 3.141592653589793, 2.4, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 19, 3.55, 2.75, 3.8415926535897933, 2.4, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 20, 3.05, 2.7, -1.5707963267948966, 2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 21, 2.85, 2.45, 0, 2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 22, 2, 2.2, 2.8415926535897933, 3.6, 0.3)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 23, 1.2, 1.6, 2.541592653589793, 4, 0.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 24, 0.6, 1.05, 2.8415926535897933, 4.4, 0.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('belgium-spa', 'f1')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('belgium-spa', 'long')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('belgium-spa', 'fast')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('belgium-spa', 'spa')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO cars (id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('f1-2024-redbull-rb20', 'Oracle Red Bull Racing RB20', 'system', 'system', 24,
|
||||
238, 83, 40, 33,
|
||||
'RB20 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
|
||||
'turbo-hybrid', 'F1 PU', 0, 746000,
|
||||
0, 0, 0, 'li-ion MGU-K',
|
||||
'2WD', 95, '#1e3a8a', '', TRUE,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO cars (id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('f1-2024-ferrari-sf24', 'Scuderia Ferrari SF-24', 'system', 'system', 24,
|
||||
238, 83, 40, 33,
|
||||
'SF-24 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
|
||||
'turbo-hybrid', 'F1 PU 066/12', 0, 746000,
|
||||
0, 0, 0, 'li-ion MGU-K',
|
||||
'2WD', 94, '#dc2626', '', TRUE,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO cars (id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('f1-2024-mclaren-mcl38', 'McLaren MCL38', 'system', 'system', 24,
|
||||
238, 83, 40, 33,
|
||||
'MCL38 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
|
||||
'turbo-hybrid', 'F1 PU Mercedes-derived', 0, 746000,
|
||||
0, 0, 0, 'li-ion MGU-K',
|
||||
'2WD', 94, '#ea580c', '', TRUE,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO cars (id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('f1-2024-mercedes-w15', 'Mercedes-AMG W15', 'system', 'system', 24,
|
||||
238, 83, 40, 33,
|
||||
'W15 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
|
||||
'turbo-hybrid', 'F1 PU M15', 0, 746000,
|
||||
0, 0, 0, 'li-ion MGU-K',
|
||||
'2WD', 94, '#06b6d4', '', TRUE,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO cars (id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('f1-2024-astonmartin-amr24', 'Aston Martin Aramco AMR24', 'system', 'system', 24,
|
||||
238, 83, 40, 33,
|
||||
'AMR24 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
|
||||
'turbo-hybrid', 'F1 PU Mercedes-derived', 0, 746000,
|
||||
0, 0, 0, 'li-ion MGU-K',
|
||||
'2WD', 94, '#16a34a', '', TRUE,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
-- 003_f1_seed.sql — replace legacy PoC seed (oval, figure-8, sprint-straight
|
||||
-- and 1/27 toy cars) with the F1 2024 catalog (5 tracks + 5 cars).
|
||||
-- Idempotent: drops the old system rows before re-inserting.
|
||||
|
||||
DELETE FROM cars WHERE id IN ('compact-1-27-touring', 'compact-1-27-formula');
|
||||
DELETE FROM tracks WHERE id IN ('oval-classic', 'figure-eight', 'sprint-straight');
|
||||
-- 002_seed.sql — initial system catalog (tracks + cars).
|
||||
-- Generated from internal/catalog/seeddefs via scripts/genseed.
|
||||
-- Idempotent: safe to re-apply (ON CONFLICT DO NOTHING).
|
||||
|
||||
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('monaco', 'Monaco', 'Circuit de Monaco. Narrow street circuit, tight hairpins, no room for error.', 'system', 'system', 27,
|
||||
3.5500000000000003, 1.1, 0.18, 'tile', 0, 'Verstappen',
|
||||
0.4, 1.4, 3.95, 2.5,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 0, 0.4, 2.2, 0, 3.2, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 1, 1.1, 2.2, 0, 3, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 2, 1.5, 2.05, -0.4, 2.4, 1.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 3, 1.7, 1.85, -0.9, 2.2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 4, 1.55, 1.55, -1.6, 2, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 5, 1.25, 1.4, -2.4, 2.1, 1.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 6, 0.95, 1.55, 3.141592653589793, 1.9, 1.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 7, 0.75, 1.85, 2, 1.8, 2.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 8, 0.95, 2.15, 2.6, 1.8, 2.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 9, 1.2, 2.4, -2.8, 2.1, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 10, 1.6, 2.5, -3, 2.4, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 11, 2.6, 2.5, 0, 3.2, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 12, 3.4, 2.5, 0, 3.2, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 13, 3.6, 2.25, -0.5, 2.6, 1.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 14, 3.55, 1.95, 0.4, 2.6, 1.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 15, 3.3, 1.75, 1, 2.3, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 16, 2.95, 1.55, 1.7, 2.1, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 17, 2.6, 1.65, 2.6, 2.1, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 18, 2.4, 1.9, -2.7, 2.3, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 19, 2.4, 2.2, -3, 2.2, 1.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 20, 2.6, 2.4, 2.8, 2.5, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 21, 3.2, 2.4, 0, 3.2, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 22, 3.7, 2.4, 0, 3.2, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('monaco', 23, 3.95, 2.3, -0.3, 2.8, 0.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'f1')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'street')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'tight')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'slow')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('monaco', 'monaco')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('barcelona-catalunya', 'Barcelona-Catalunya', 'Circuit de Barcelona-Catalunya. Mix of high-speed corners and technical chicanes.', 'system', 'system', 27,
|
||||
3.75, 2.3000000000000003, 0.18, 'tile', 0, 'Verstappen',
|
||||
0.3, 0.4, 4.05, 2.7,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 0, 0.3, 0.4, 0, 4.5, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 1, 1.4, 0.4, 0, 4.5, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 2, 1.7, 0.65, 1.5707963267948966, 2.2, 1.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 3, 1.5, 0.95, 3.141592653589793, 2, 2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 4, 1.2, 1.1, 3.4415926535897934, 2.6, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 5, 0.95, 1, 4.0415926535897935, 2.6, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 6, 0.8, 1.2, -1.5707963267948966, 2.4, 1.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 7, 1.05, 1.5, 0, 3.2, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 8, 1.3, 1.65, 1.5707963267948966, 2.4, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 9, 1.15, 1.85, 3.141592653589793, 2.4, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 10, 1.4, 2, 2.641592653589793, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 11, 1.7, 2, 2.141592653589793, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 12, 1.95, 2.2, 1.5707963267948966, 2.8, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 13, 1.65, 2.5, 0.3, 2.8, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 14, 2.2, 2.7, 0.7, 3.2, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 15, 2.8, 2.7, 1.1, 3.4, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 16, 3.1, 2.5, 1.9707963267948967, 2.2, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 17, 2.95, 2.2, 3.541592653589793, 2.2, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 18, 3.3, 1.9, 0, 4, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 19, 4, 1.9, 0, 4, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 20, 4.05, 1.6, -1.5707963267948966, 2.4, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 21, 3.8, 1.4, 3.141592653589793, 2.4, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 22, 3.5, 1.5, 3.9269908169872414, 2.6, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 23, 3.1, 1.25, -1.9707963267948967, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 24, 2.7, 1, -2.4707963267948965, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 25, 2.3, 0.7, 3.541592653589793, 3.4, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 26, 1.6, 0.5, 3.3415926535897933, 3.6, 0.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('barcelona-catalunya', 27, 0.9, 0.42, 0, 4, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('barcelona-catalunya', 'f1')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('barcelona-catalunya', 'balanced')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('barcelona-catalunya', 'testing')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('barcelona-catalunya', 'barcelona')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('austria-redbull-ring', 'Austria (Red Bull Ring)', 'Red Bull Ring. Short, fast, three straights separated by hairpins.', 'system', 'system', 27,
|
||||
3.5500000000000003, 2, 0.18, 'wood', 0, 'Verstappen',
|
||||
0.3, 0.4, 3.85, 2.4,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 0, 0.3, 0.4, 0, 4.8, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 1, 1.6, 0.4, 0, 4.8, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 2, 1.85, 0.65, 1.5707963267948966, 2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 3, 1.6, 0.95, 3.141592653589793, 2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 4, 1.3, 1, 3.641592653589793, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 5, 1.05, 1.2, -1.5707963267948966, 2.6, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 6, 1.4, 1.4, 0, 3.2, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 7, 1.85, 1.4, 1.7707963267948965, 3.2, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 8, 2.1, 1.65, 3.141592653589793, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 9, 2, 1.85, -1.5707963267948966, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 10, 2.25, 2.05, 0, 2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 11, 2.4, 2, 1.5707963267948966, 2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 12, 2.4, 2.2, 3.141592653589793, 2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 13, 2.2, 2.3, -1.5707963267948966, 2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 14, 2.55, 2.4, -0.2, 3.4, 0.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 15, 3, 2.4, 0, 4, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 16, 3.5, 2.4, 0, 4.5, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 17, 3.85, 2.15, 1.7707963267948965, 3.4, 0.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 18, 3.6, 1.8, 3.141592653589793, 3, 0.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 19, 3.25, 1.85, 3.741592653589793, 3, 0.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 20, 3, 1.6, -1.9707963267948967, 3.2, 0.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 21, 2.6, 1.4, -3.141592653589793, 3.4, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 22, 2, 1.2, 2.641592653589793, 3.6, 0.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 23, 1.4, 0.8, 2.9415926535897934, 3.8, 0.3)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('austria-redbull-ring', 24, 0.8, 0.5, 0, 4.2, 0.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('austria-redbull-ring', 'f1')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('austria-redbull-ring', 'short')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('austria-redbull-ring', 'fast')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('austria-redbull-ring', 'austria')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('great-britain-silverstone', 'Great Britain (Silverstone)', 'Silverstone Circuit. Fast, flowing corners; home of British motorsport.', 'system', 'system', 27,
|
||||
3.5500000000000003, 2.4, 0.18, 'carpet', 0, 'Hamilton',
|
||||
0.3, 0.25, 3.85, 2.65,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 0, 0.3, 0.3, 0, 5, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 1, 2.4, 0.3, 0, 5, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 2, 2.8, 0.45, 1.5707963267948966, 3, 0.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 3, 2.6, 0.8, 3.141592653589793, 3.4, 0.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 4, 2.95, 1.05, 2.0707963267948966, 3.2, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 5, 3.3, 1.3, 0, 2.2, 1.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 6, 3.6, 1.15, -1.5707963267948966, 2.2, 1.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 7, 3.6, 0.8, -3.141592653589793, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 8, 3.85, 0.55, -1.5707963267948966, 2.6, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 9, 3.55, 0.25, 0, 2.4, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 10, 3.25, 0.25, 1.5707963267948966, 2.4, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 11, 3.05, 0.55, 1.8707963267948966, 3.4, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 12, 2.85, 0.45, 3.141592653589793, 4, 0.3)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 13, 1.8, 0.3, 0, 4.6, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 14, 1.2, 0.45, -1.7707963267948965, 3.8, 0.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 15, 1, 0.85, -3.541592653589793, 3.6, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 16, 0.7, 1.15, 1.8707963267948966, 2.4, 1.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 17, 0.5, 0.9, 0, 2.6, 0.9)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 18, 0.7, 0.65, -1.5707963267948966, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 19, 0.45, 0.45, -3.541592653589793, 2.6, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 20, 0.75, 1, 1.1707963267948966, 2.4, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 21, 0.5, 1.5, -3.541592653589793, 2.6, 0.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 22, 0.3, 1.15, -1.5707963267948966, 2.4, 1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 23, 0.7, 1.6, 0.7853981633974483, 3.4, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 24, 2, 1.95, 1.9707963267948967, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 25, 3, 2.4, 3.3415926535897933, 3.4, 0.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 26, 2.4, 2.65, -1.8707963267948966, 4, 0.3)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 27, 1.4, 2.6, -3.141592653589793, 4.4, 0.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 28, 0.5, 2.3, -2.8415926535897933, 4.8, 0.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('great-britain-silverstone', 29, 0.3, 1.7, -1.2707963267948965, 5, 0.1)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('great-britain-silverstone', 'f1')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('great-britain-silverstone', 'fast')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('great-britain-silverstone', 'flowing')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('great-britain-silverstone', 'silverstone')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('belgium-spa', 'Belgium (Spa-Francorchamps)', 'Spa-Francorchamps. Long, fast, legendary Eau Rouge / Raidillon.', 'system', 'system', 27,
|
||||
3.85, 2.25, 0.18, 'carpet', 0, 'Verstappen',
|
||||
0.4, 0.5, 4.25, 2.75,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 0, 0.4, 0.5, 0, 4.8, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 1, 1.6, 0.5, 0, 4.8, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 2, 1.95, 0.6, 0.3, 4.2, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 3, 2.2, 0.95, 0.9, 4, 0.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 4, 2.1, 1.3, 3.541592653589793, 4, 0.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 5, 2.7, 1.5, 0, 5, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 6, 3.5, 1.5, 0, 5, 0)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 7, 3.85, 1.3, -1.5707963267948966, 2.4, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 8, 3.6, 1.1, 3.4415926535897934, 2.6, 1.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 9, 3.3, 1.25, 4.0415926535897935, 2.6, 1.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 10, 3.5, 1.55, 1.5707963267948966, 3.6, 0.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 11, 3.25, 1.85, 0, 2.2, 1.6)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 12, 3.05, 1.7, -1.5707963267948966, 2.4, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 13, 3.25, 1.5, 0, 3, 0.7)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 14, 3.7, 1.65, 1.8707963267948966, 3.4, 0.5)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 15, 4.1, 2, 2.1707963267948966, 4, 0.3)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 16, 4.25, 2.4, 3.541592653589793, 4.6, 0.3)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 17, 4, 2.7, -1.8707963267948966, 2.4, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 18, 3.7, 2.55, 3.141592653589793, 2.4, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 19, 3.55, 2.75, 3.8415926535897933, 2.4, 1.4)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 20, 3.05, 2.7, -1.5707963267948966, 2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 21, 2.85, 2.45, 0, 2, 1.8)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 22, 2, 2.2, 2.8415926535897933, 3.6, 0.3)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 23, 1.2, 1.6, 2.541592653589793, 4, 0.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ('belgium-spa', 24, 0.6, 1.05, 2.8415926535897933, 4.4, 0.2)
|
||||
ON CONFLICT (track_id, seq) DO NOTHING;
|
||||
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('belgium-spa', 'f1')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('belgium-spa', 'long')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('belgium-spa', 'fast')
|
||||
ON CONFLICT DO NOTHING;
|
||||
INSERT INTO track_tags (track_id, tag) VALUES ('belgium-spa', 'spa')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO cars (id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('f1-2024-redbull-rb20', 'Oracle Red Bull Racing RB20', 'system', 'system', 24,
|
||||
238, 83, 40, 33,
|
||||
'RB20 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
|
||||
'turbo-hybrid', 'F1 PU', 0, 746000,
|
||||
0, 0, 0, 'li-ion MGU-K',
|
||||
'2WD', 95, '#1e3a8a', '', TRUE,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO cars (id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('f1-2024-ferrari-sf24', 'Scuderia Ferrari SF-24', 'system', 'system', 24,
|
||||
238, 83, 40, 33,
|
||||
'SF-24 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
|
||||
'turbo-hybrid', 'F1 PU 066/12', 0, 746000,
|
||||
0, 0, 0, 'li-ion MGU-K',
|
||||
'2WD', 94, '#dc2626', '', TRUE,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO cars (id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('f1-2024-mclaren-mcl38', 'McLaren MCL38', 'system', 'system', 24,
|
||||
238, 83, 40, 33,
|
||||
'MCL38 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
|
||||
'turbo-hybrid', 'F1 PU Mercedes-derived', 0, 746000,
|
||||
0, 0, 0, 'li-ion MGU-K',
|
||||
'2WD', 94, '#ea580c', '', TRUE,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO cars (id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('f1-2024-mercedes-w15', 'Mercedes-AMG W15', 'system', 'system', 24,
|
||||
238, 83, 40, 33,
|
||||
'W15 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
|
||||
'turbo-hybrid', 'F1 PU M15', 0, 746000,
|
||||
0, 0, 0, 'li-ion MGU-K',
|
||||
'2WD', 94, '#06b6d4', '', TRUE,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO cars (id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
created_ms, updated_ms)
|
||||
VALUES ('f1-2024-astonmartin-amr24', 'Aston Martin Aramco AMR24', 'system', 'system', 24,
|
||||
238, 83, 40, 33,
|
||||
'AMR24 carbon monocoque', 'carbon-fibre', FALSE, 150, 75,
|
||||
'turbo-hybrid', 'F1 PU Mercedes-derived', 0, 746000,
|
||||
0, 0, 0, 'li-ion MGU-K',
|
||||
'2WD', 94, '#16a34a', '', TRUE,
|
||||
1700000000000, 1700000000000)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
-- 004_monaco_rescale.sql
|
||||
-- Replace Monaco centerline (seq 0..23) inserted by 003_f1_seed.sql
|
||||
-- with the new 51-waypoint CSV at D:/x0gp/tracks_waypoints_10x10.csv
|
||||
-- Source CSV: 51 waypoints, scale_xy=0.016 (about 1/62.5), v_scale=sqrt(scale)=0.1265.
|
||||
-- New footprint: 6.46 x 5.37 m, perimeter approx 21.87 m.
|
||||
-- Idempotent: DELETE old waypoints first, UPDATE tracks metadata, INSERT new ones.
|
||||
|
||||
BEGIN;
|
||||
|
||||
DELETE FROM track_waypoints WHERE track_id = 'monaco';
|
||||
|
||||
UPDATE tracks SET
|
||||
length_m = 21.87,
|
||||
width_m = 6.46,
|
||||
bounds_min_x = -3.231,
|
||||
bounds_min_y = -2.684,
|
||||
bounds_max_x = 3.231,
|
||||
bounds_max_y = 2.684,
|
||||
updated_ms = (EXTRACT(EPOCH FROM now()) * 1000)::bigint
|
||||
WHERE id = 'monaco';
|
||||
|
||||
-- New centerline (51 waypoints, scale_xy=0.016, v=0.632 m/s)
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 0, -3.2064, -1.0454, 1.4710, 0.632, -0.1988);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 1, -3.1387, -0.5857, 1.3926, 0.632, -0.1787);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 2, -3.0418, -0.1312, 1.3043, 0.632, -0.2544);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 3, -2.8946, 0.3084, 1.1557, 0.632, -0.4431);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 4, -2.6693, 0.7141, 0.8629, 0.632, -1.0875);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 5, -2.3517, 0.9426, 0.1919, 0.632, -1.2844);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 6, -1.8935, 0.8648, -0.1182, 0.632, -0.2237);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 7, -1.4302, 0.8332, -0.0660, 0.632, 0.0475);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 8, -0.9667, 0.8035, -0.0743, 0.632, 0.0756);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 9, -0.5052, 0.7644, 0.0045, 0.632, 0.0806);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 10, -0.0425, 0.8077, 0.0000, 0.632, -0.0425);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 11, 0.4197, 0.7644, -0.0351, 0.632, 0.3650);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 12, 0.8809, 0.7753, 0.3862, 0.632, 1.3887);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 13, 1.1985, 1.0811, 1.2440, 0.632, 1.4550);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 14, 1.1372, 1.5315, 1.5878, 0.632, -0.2144);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 15, 1.1839, 1.9468, 1.0284, 0.632, -1.2681);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 16, 1.5551, 2.2249, 0.6162, 0.632, -0.4763);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 17, 1.9414, 2.4832, 0.5226, 0.632, -0.1756);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 18, 2.3526, 2.6842, 0.4546, 0.632, -0.1494);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 19, 2.3526, 2.6842, -1.2476, 0.632, -0.0225);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 20, 2.4622, 2.3570, -1.2545, 0.632, 0.1775);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 21, 2.5976, 1.9356, -0.5751, 0.632, 34.1925);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 22, 2.6154, 2.2577, 1.0415, 0.632, -0.8400);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 23, 2.9239, 2.4933, -0.0053, 0.632, -3.1975);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 24, 3.2311, 2.2544, -1.4159, 0.632, -2.7456);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 25, 3.0259, 1.8399, -2.1127, 0.632, -0.6488);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 26, 2.7546, 1.4629, -2.2528, 0.632, -0.2769);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 27, 2.4413, 1.1199, -2.3708, 0.632, -0.3044);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 28, 2.0895, 0.8170, -2.5300, 0.632, -0.3825);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 29, 1.6852, 0.5897, -2.7255, 0.632, -0.4019);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 30, 1.2447, 0.4437, -2.9031, 0.632, -0.3538);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 31, 0.7862, 0.3711, -3.0547, 0.632, -0.3044);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 32, 0.3218, 0.3632, 3.0973, 0.632, -0.0975);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 33, -0.1396, 0.4121, 3.1387, 0.632, -0.0394);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 34, -0.5818, 0.3659, 3.0594, 0.632, -0.2375);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 35, -1.0290, 0.4854, 2.9319, 0.632, -0.0950);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 36, -1.4880, 0.5588, 2.9664, 0.632, -0.2500);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 37, -1.9441, 0.6474, -3.1052, 0.632, 0.8825);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 38, -2.3631, 0.5269, -2.4971, 0.632, 1.3600);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 39, -2.6181, 0.1409, -1.9547, 0.632, 0.9481);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 40, -2.7014, -0.3108, -1.5706, 0.632, 0.3788);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 41, -2.6179, -0.7633, -1.6025, 0.632, -0.3425);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 42, -2.7299, -1.2092, -1.8788, 0.632, -0.0725);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 43, -2.8930, -1.6280, -1.6659, 0.632, 0.7706);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 44, -2.8134, -2.0840, -1.2045, 0.632, 0.2162);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 45, -2.5685, -2.4739, -1.5682, 0.632, -2.4475);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 46, -2.8118, -2.6842, 3.0638, 0.632, -4.0544);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 47, -3.1281, -2.4303, 2.0706, 0.632, -1.2500);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 48, -3.2004, -1.9728, 1.6822, 0.632, -0.4706);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 49, -3.2311, -1.5093, 1.5774, 0.632, -0.2275);
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature) VALUES ('monaco', 50, -3.2064, -1.0454, 1.4710, 0.632, -0.1988);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,90 @@
|
||||
-- 005_races.sql — race plan, finished race archive, queue.
|
||||
--
|
||||
-- Storage split:
|
||||
-- * Active races (lobby/countdown/racing) live in-memory in lobby.Service.
|
||||
-- * Finished races are snapshotted into finished_races on SetRaceStatus(finished).
|
||||
-- * Race plans describe recurring / one-off scheduled races; the scheduler
|
||||
-- materializes them into lobby.RaceMeta as their start_at_ms approaches.
|
||||
-- * Queue entries are per-driver per-race_id subscriptions to the next
|
||||
-- upcoming races. Driver is auto-attached when a slot opens.
|
||||
--
|
||||
-- Keyset pagination:
|
||||
-- * finished_races ordered by (finished_ms DESC, id DESC). Cursor encodes
|
||||
-- (finished_ms, id); lookup uses (finished_ms, id) < (cursor_ms, cursor_id).
|
||||
-- * race_plans ordered by (start_at_ms ASC, id ASC). Cursor is
|
||||
-- (start_at_ms, id) with strict-less comparison.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS finished_races (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
host_id TEXT NOT NULL,
|
||||
host_name TEXT NOT NULL DEFAULT '',
|
||||
track_id TEXT NOT NULL DEFAULT '',
|
||||
max_cars INT NOT NULL,
|
||||
laps INT NOT NULL,
|
||||
time_limit_s INT NOT NULL,
|
||||
driver_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'finished'
|
||||
CHECK (status IN ('finished', 'cancelled')),
|
||||
created_ms BIGINT NOT NULL,
|
||||
started_ms BIGINT NOT NULL DEFAULT 0,
|
||||
finished_ms BIGINT NOT NULL,
|
||||
duration_ms BIGINT NOT NULL DEFAULT 0,
|
||||
total_laps INT NOT NULL DEFAULT 0,
|
||||
total_drivers INT NOT NULL DEFAULT 0,
|
||||
winner_driver_id TEXT NOT NULL DEFAULT '',
|
||||
winner_name TEXT NOT NULL DEFAULT '',
|
||||
best_lap_ms BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS finished_races_finished_ms_id_idx
|
||||
ON finished_races (finished_ms DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS finished_races_host_idx
|
||||
ON finished_races (host_id);
|
||||
CREATE INDEX IF NOT EXISTS finished_races_track_idx
|
||||
ON finished_races (track_id);
|
||||
CREATE INDEX IF NOT EXISTS finished_races_status_idx
|
||||
ON finished_races (status);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Race plans
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS race_plans (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
track_id TEXT NOT NULL DEFAULT 'default',
|
||||
max_cars INT NOT NULL,
|
||||
laps INT NOT NULL DEFAULT 0,
|
||||
time_limit_s INT NOT NULL DEFAULT 0,
|
||||
host_id TEXT NOT NULL,
|
||||
host_name TEXT NOT NULL DEFAULT '',
|
||||
start_at_ms BIGINT NOT NULL,
|
||||
interval_s INT NOT NULL DEFAULT 0, -- 0 = single-shot
|
||||
count INT NOT NULL DEFAULT 0, -- 0 = repeat forever
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_ms BIGINT NOT NULL,
|
||||
updated_ms BIGINT NOT NULL,
|
||||
next_fire_ms BIGINT NOT NULL, -- last materialised fire time
|
||||
fires_done INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS race_plans_start_idx
|
||||
ON race_plans (start_at_ms ASC, id ASC);
|
||||
CREATE INDEX IF NOT EXISTS race_plans_enabled_idx
|
||||
ON race_plans (enabled, next_fire_ms);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Queue
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS race_queue (
|
||||
driver_id TEXT NOT NULL,
|
||||
race_id TEXT NOT NULL,
|
||||
plan_id TEXT NOT NULL DEFAULT '',
|
||||
enqueued_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (driver_id, race_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS race_queue_race_idx ON race_queue (race_id);
|
||||
CREATE INDEX IF NOT EXISTS race_queue_driver_idx ON race_queue (driver_id, enqueued_ms);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 006_drop_host.sql — remove host_id and host_name from race tables.
|
||||
--
|
||||
-- Races no longer carry a host (driver) reference. A race is owned by a
|
||||
-- physical track; the "host" concept is removed from the public API and
|
||||
-- from lobby.RaceMeta. Drivers participate as a flat list of driver_ids.
|
||||
|
||||
ALTER TABLE finished_races DROP COLUMN IF EXISTS host_id;
|
||||
ALTER TABLE finished_races DROP COLUMN IF EXISTS host_name;
|
||||
DROP INDEX IF EXISTS finished_races_host_idx;
|
||||
|
||||
ALTER TABLE race_plans DROP COLUMN IF EXISTS host_id;
|
||||
ALTER TABLE race_plans DROP COLUMN IF EXISTS host_name;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 007_podium.sql — add podium to finished_races.
|
||||
--
|
||||
-- Podium is the top-3 finishers of a completed race. Stored as JSONB
|
||||
-- (a small ordered list, rarely queried as a relation). Shape per row:
|
||||
-- { "position": 1, "driver_id": "driver-alice",
|
||||
-- "name": "driver-alice", "total_time_ms": 123456 }
|
||||
--
|
||||
-- Ordering: position 1 first.
|
||||
-- GIN index over the jsonb lets future UI filters (e.g. "races won by
|
||||
-- driver X") use @> containment queries without a schema change.
|
||||
|
||||
ALTER TABLE finished_races
|
||||
ADD COLUMN IF NOT EXISTS podium JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS finished_races_podium_gin
|
||||
ON finished_races USING GIN (podium);
|
||||
@@ -0,0 +1,37 @@
|
||||
-- 008_drivers_clans.sql — drivers and clans.
|
||||
--
|
||||
-- A driver is a person who can join races. Each driver has a unique
|
||||
-- 3-letter nickname (A-Z, uppercase, exactly 3 characters) and an
|
||||
-- optional avatar URL. Drivers can belong to at most one clan.
|
||||
--
|
||||
-- A clan is a team that groups drivers. Each clan has a unique 3-letter
|
||||
-- tag (same rules as the driver nickname) and an optional avatar URL.
|
||||
--
|
||||
-- Constraints:
|
||||
-- * nickname is exactly 3 uppercase ASCII letters and unique.
|
||||
-- * tag is exactly 3 uppercase ASCII letters and unique.
|
||||
-- * driver.clan_id is a soft FK (nullable); ON DELETE SET NULL.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clans (
|
||||
id TEXT PRIMARY KEY,
|
||||
tag TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
created_ms BIGINT NOT NULL,
|
||||
updated_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS clans_tag_idx ON clans (tag);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS drivers (
|
||||
id TEXT PRIMARY KEY,
|
||||
nickname TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
clan_id TEXT REFERENCES clans(id) ON DELETE SET NULL,
|
||||
created_ms BIGINT NOT NULL,
|
||||
updated_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS drivers_nickname_idx ON drivers (nickname);
|
||||
CREATE INDEX IF NOT EXISTS drivers_clan_idx ON drivers (clan_id);
|
||||
@@ -0,0 +1,68 @@
|
||||
-- 009_live_persistence.sql — persistence for live races and lobby drivers.
|
||||
--
|
||||
-- The active race surface (lobby | countdown | racing) is now mirrored
|
||||
-- in Postgres. finished_races continues to be the canonical archive
|
||||
-- (no rename yet — it stays in the same shape and the next migration
|
||||
-- will unify the two under a single `races` table).
|
||||
--
|
||||
-- live_races
|
||||
-- One row per active race. Status is one of
|
||||
-- lobby | countdown | racing. The `podium` column is reserved for
|
||||
-- future use (we may eventually move finished_podium into this
|
||||
-- table as well).
|
||||
--
|
||||
-- live_race_drivers
|
||||
-- Many-to-many between live_races and drivers. The order column is
|
||||
-- the car's slot in the race (0..max_cars-1).
|
||||
--
|
||||
-- lobby_drivers
|
||||
-- Presence of a driver in the lobby: connected / idle / racing /
|
||||
-- offline and the current race if racing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS live_races (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
track_id TEXT NOT NULL,
|
||||
max_cars INT NOT NULL,
|
||||
laps INT NOT NULL,
|
||||
time_limit_s INT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'lobby'
|
||||
CHECK (status IN ('lobby', 'countdown', 'racing', 'cancelled')),
|
||||
created_ms BIGINT NOT NULL,
|
||||
started_ms BIGINT NOT NULL DEFAULT 0,
|
||||
finished_ms BIGINT NOT NULL DEFAULT 0,
|
||||
updated_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS live_races_status_idx
|
||||
ON live_races (status, updated_ms DESC);
|
||||
CREATE INDEX IF NOT EXISTS live_races_track_idx
|
||||
ON live_races (track_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS live_race_drivers (
|
||||
race_id TEXT NOT NULL REFERENCES live_races(id) ON DELETE CASCADE,
|
||||
driver_id TEXT NOT NULL,
|
||||
slot INT NOT NULL DEFAULT 0,
|
||||
joined_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (race_id, driver_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS live_race_drivers_driver_idx
|
||||
ON live_race_drivers (driver_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lobby_drivers (
|
||||
driver_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
nickname TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
clan_id TEXT NOT NULL DEFAULT '',
|
||||
clan_tag TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'idle'
|
||||
CHECK (status IN ('idle', 'racing', 'offline')),
|
||||
current_race_id TEXT NOT NULL DEFAULT '',
|
||||
connected_ms BIGINT NOT NULL,
|
||||
last_seen_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS lobby_drivers_status_idx
|
||||
ON lobby_drivers (status);
|
||||
@@ -0,0 +1,65 @@
|
||||
-- 010_unify_races.sql — unify finished_races and live_races into a single
|
||||
-- `races` table with a 5-state status.
|
||||
--
|
||||
-- After this migration the `races` table holds both the live
|
||||
-- surface (lobby | countdown | racing) and the finished archive
|
||||
-- (finished | cancelled). live_race_drivers keeps the same shape
|
||||
-- (one row per (race, driver)). The former `finished_races` columns
|
||||
-- are kept verbatim; the former `live_races` columns are added
|
||||
-- (started_ms is reused, created_ms was there too).
|
||||
|
||||
-- 1) Rename finished_races -> races.
|
||||
ALTER TABLE finished_races RENAME TO races;
|
||||
|
||||
-- 2) Drop the old finished-only check and replace with a unified one.
|
||||
ALTER TABLE races DROP CONSTRAINT IF EXISTS finished_races_status_check;
|
||||
ALTER TABLE races ADD CONSTRAINT races_status_check
|
||||
CHECK (status IN ('lobby', 'countdown', 'racing', 'finished', 'cancelled'));
|
||||
|
||||
-- 2a) Add the live-only columns to the (former) finished table.
|
||||
ALTER TABLE races ADD COLUMN IF NOT EXISTS updated_ms BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- 3) Drop the old index that referenced the old table name and
|
||||
-- recreate it. The new keyset index sorts on (status, finished_ms
|
||||
-- DESC, id DESC) so the list view can read live+finished together
|
||||
-- when needed.
|
||||
DROP INDEX IF EXISTS finished_races_finished_ms_id_idx;
|
||||
CREATE INDEX IF NOT EXISTS races_status_finished_idx
|
||||
ON races (status, finished_ms DESC, id DESC);
|
||||
DROP INDEX IF EXISTS finished_races_host_idx; -- host_id no longer exists, but the
|
||||
-- index name may have survived. Defensive drop.
|
||||
CREATE INDEX IF NOT EXISTS races_started_idx
|
||||
ON races (status, started_ms DESC, id DESC);
|
||||
|
||||
-- 4) Move live rows from live_races into races BEFORE renaming
|
||||
-- live_race_drivers, so the FK has somewhere to point.
|
||||
INSERT INTO races
|
||||
(id, name, track_id, max_cars, laps, time_limit_s, status,
|
||||
created_ms, started_ms, finished_ms, updated_ms, podium)
|
||||
SELECT
|
||||
id, name, track_id, max_cars, laps, time_limit_s, status,
|
||||
created_ms, started_ms, 0, updated_ms, '[]'::jsonb
|
||||
FROM live_races
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
track_id = EXCLUDED.track_id,
|
||||
max_cars = EXCLUDED.max_cars,
|
||||
laps = EXCLUDED.laps,
|
||||
time_limit_s = EXCLUDED.time_limit_s,
|
||||
status = EXCLUDED.status,
|
||||
created_ms = EXCLUDED.created_ms,
|
||||
started_ms = EXCLUDED.started_ms,
|
||||
updated_ms = EXCLUDED.updated_ms;
|
||||
|
||||
-- 5) live_race_drivers: rename to race_drivers and re-target the FK
|
||||
-- at the unified races table.
|
||||
ALTER TABLE live_race_drivers RENAME TO race_drivers;
|
||||
ALTER TABLE race_drivers
|
||||
DROP CONSTRAINT IF EXISTS live_race_drivers_race_id_fkey;
|
||||
ALTER TABLE race_drivers
|
||||
ADD CONSTRAINT race_drivers_race_id_fkey
|
||||
FOREIGN KEY (race_id) REFERENCES races(id) ON DELETE CASCADE;
|
||||
|
||||
-- 6) Drop the now-redundant live tables.
|
||||
DROP TABLE IF EXISTS live_race_drivers;
|
||||
DROP TABLE IF EXISTS live_races;
|
||||
@@ -0,0 +1,373 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/x0gp/server/internal/catalog"
|
||||
)
|
||||
|
||||
// PgStore implements catalog.Store backed by Postgres + TimescaleDB.
|
||||
type PgStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPgStore wraps an open pool.
|
||||
func NewPgStore(pool *pgxpool.Pool) *PgStore {
|
||||
return &PgStore{pool: pool}
|
||||
}
|
||||
|
||||
// Compile-time check.
|
||||
var _ catalog.Store = (*PgStore)(nil)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Load reads the full catalog into memory. Used at startup.
|
||||
func (s *PgStore) Load(ctx context.Context) ([]catalog.TrackMeta, []catalog.CarMeta, error) {
|
||||
tracks, err := s.loadTracks(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load tracks: %w", err)
|
||||
}
|
||||
cars, err := s.loadCars(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load cars: %w", err)
|
||||
}
|
||||
return tracks, cars, nil
|
||||
}
|
||||
|
||||
func (s *PgStore) loadTracks(ctx context.Context) ([]catalog.TrackMeta, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface,
|
||||
best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms
|
||||
FROM tracks
|
||||
ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]catalog.TrackMeta, 0)
|
||||
for rows.Next() {
|
||||
var t catalog.TrackMeta
|
||||
if err := rows.Scan(
|
||||
&t.ID, &t.Name, &t.Description, &t.AuthorID, &t.Visibility, &t.Scale,
|
||||
&t.LengthM, &t.WidthM, &t.LaneWidthM, &t.Surface,
|
||||
&t.BestLapMs, &t.BestLapHolder,
|
||||
&t.Bounds.MinX, &t.Bounds.MinY, &t.Bounds.MaxX, &t.Bounds.MaxY,
|
||||
&t.CreatedMs, &t.UpdatedMs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Derive bounds length/width if not stored explicitly.
|
||||
t.Bounds.LengthM = t.Bounds.MaxX - t.Bounds.MinX
|
||||
t.Bounds.WidthM = t.Bounds.MaxY - t.Bounds.MinY
|
||||
out = append(out, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fetch waypoints and tags per track.
|
||||
for i := range out {
|
||||
wps, err := s.loadWaypoints(ctx, out[i].ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("waypoints %s: %w", out[i].ID, err)
|
||||
}
|
||||
out[i].Centerline = wps
|
||||
tags, err := s.loadTags(ctx, out[i].ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tags %s: %w", out[i].ID, err)
|
||||
}
|
||||
out[i].Tags = tags
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PgStore) loadWaypoints(ctx context.Context, trackID string) ([]catalog.Waypoint, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT x, y, heading_rad, speed_ms, curvature
|
||||
FROM track_waypoints
|
||||
WHERE track_id = $1
|
||||
ORDER BY seq`, trackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]catalog.Waypoint, 0)
|
||||
for rows.Next() {
|
||||
var w catalog.Waypoint
|
||||
if err := rows.Scan(&w.X, &w.Y, &w.HeadingRad, &w.SpeedMs, &w.Curvature); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PgStore) loadTags(ctx context.Context, trackID string) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT tag FROM track_tags WHERE track_id = $1 ORDER BY tag`, trackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var tag string
|
||||
if err := rows.Scan(&tag); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, tag)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PgStore) loadCars(ctx context.Context) ([]catalog.CarMeta, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed,
|
||||
chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
total_distance_m, total_races, total_laps, best_lap_ms,
|
||||
created_ms, updated_ms
|
||||
FROM cars
|
||||
ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]catalog.CarMeta, 0)
|
||||
for rows.Next() {
|
||||
var c catalog.CarMeta
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.Name, &c.OwnerID, &c.Visibility, &c.Scale,
|
||||
&c.LengthMm, &c.WidthMm, &c.HeightMm, &c.WeightG,
|
||||
&c.Chassis.Model, &c.Chassis.Material, &c.Chassis.Printed,
|
||||
&c.Chassis.WheelbaseMm, &c.Chassis.TrackMm,
|
||||
&c.Motor.Kind, &c.Motor.Class, &c.Motor.KV, &c.Motor.PowerW,
|
||||
&c.Battery.VoltageV, &c.Battery.CapacityMah, &c.Battery.Cells, &c.Battery.Chemistry,
|
||||
&c.Drive, &c.TopSpeedMs, &c.ColorHex, &c.AvatarURL, &c.Active,
|
||||
&c.TotalDistanceM, &c.TotalRaces, &c.TotalLaps, &c.BestLapMs,
|
||||
&c.CreatedMs, &c.UpdatedMs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpsertTrack writes the track, its waypoints, and its tags in one transaction.
|
||||
func (s *PgStore) UpsertTrack(ctx context.Context, t catalog.TrackMeta) error {
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
|
||||
length_m, width_m, lane_width_m, surface,
|
||||
best_lap_ms, best_lap_holder,
|
||||
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
|
||||
created_ms, updated_ms)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
author_id = EXCLUDED.author_id,
|
||||
visibility = EXCLUDED.visibility,
|
||||
scale = EXCLUDED.scale,
|
||||
length_m = EXCLUDED.length_m,
|
||||
width_m = EXCLUDED.width_m,
|
||||
lane_width_m = EXCLUDED.lane_width_m,
|
||||
surface = EXCLUDED.surface,
|
||||
best_lap_ms = EXCLUDED.best_lap_ms,
|
||||
best_lap_holder = EXCLUDED.best_lap_holder,
|
||||
bounds_min_x = EXCLUDED.bounds_min_x,
|
||||
bounds_min_y = EXCLUDED.bounds_min_y,
|
||||
bounds_max_x = EXCLUDED.bounds_max_x,
|
||||
bounds_max_y = EXCLUDED.bounds_max_y,
|
||||
updated_ms = EXCLUDED.updated_ms`,
|
||||
t.ID, t.Name, t.Description, t.AuthorID, string(t.Visibility), t.Scale,
|
||||
t.LengthM, t.WidthM, t.LaneWidthM, string(t.Surface),
|
||||
t.BestLapMs, t.BestLapHolder,
|
||||
t.Bounds.MinX, t.Bounds.MinY, t.Bounds.MaxX, t.Bounds.MaxY,
|
||||
t.CreatedMs, t.UpdatedMs,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM track_waypoints WHERE track_id = $1`, t.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, w := range t.Centerline {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
t.ID, i, w.X, w.Y, w.HeadingRad, w.SpeedMs, w.Curvature,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM track_tags WHERE track_id = $1`, t.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, tag := range t.Tags {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO track_tags (track_id, tag) VALUES ($1, $2)`,
|
||||
t.ID, tag,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// DeleteTrack removes a track and all child rows (cascade on FK).
|
||||
func (s *PgStore) DeleteTrack(ctx context.Context, id string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM tracks WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertCar writes a car (insert or update).
|
||||
func (s *PgStore) UpsertCar(ctx context.Context, c catalog.CarMeta) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO cars (id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed,
|
||||
chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
total_distance_m, total_races, total_laps, best_lap_ms,
|
||||
created_ms, updated_ms)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
owner_id = EXCLUDED.owner_id,
|
||||
visibility = EXCLUDED.visibility,
|
||||
scale = EXCLUDED.scale,
|
||||
length_mm = EXCLUDED.length_mm,
|
||||
width_mm = EXCLUDED.width_mm,
|
||||
height_mm = EXCLUDED.height_mm,
|
||||
weight_g = EXCLUDED.weight_g,
|
||||
chassis_model = EXCLUDED.chassis_model,
|
||||
chassis_material = EXCLUDED.chassis_material,
|
||||
chassis_printed = EXCLUDED.chassis_printed,
|
||||
chassis_wheelbase_mm= EXCLUDED.chassis_wheelbase_mm,
|
||||
chassis_track_mm = EXCLUDED.chassis_track_mm,
|
||||
motor_kind = EXCLUDED.motor_kind,
|
||||
motor_class = EXCLUDED.motor_class,
|
||||
motor_kv = EXCLUDED.motor_kv,
|
||||
motor_power_w = EXCLUDED.motor_power_w,
|
||||
battery_voltage_v = EXCLUDED.battery_voltage_v,
|
||||
battery_capacity_mah= EXCLUDED.battery_capacity_mah,
|
||||
battery_cells = EXCLUDED.battery_cells,
|
||||
battery_chemistry = EXCLUDED.battery_chemistry,
|
||||
drive = EXCLUDED.drive,
|
||||
top_speed_ms = EXCLUDED.top_speed_ms,
|
||||
color_hex = EXCLUDED.color_hex,
|
||||
avatar_url = EXCLUDED.avatar_url,
|
||||
active = EXCLUDED.active,
|
||||
total_distance_m = EXCLUDED.total_distance_m,
|
||||
total_races = EXCLUDED.total_races,
|
||||
total_laps = EXCLUDED.total_laps,
|
||||
best_lap_ms = EXCLUDED.best_lap_ms,
|
||||
updated_ms = EXCLUDED.updated_ms`,
|
||||
c.ID, c.Name, c.OwnerID, string(c.Visibility), c.Scale,
|
||||
c.LengthMm, c.WidthMm, c.HeightMm, c.WeightG,
|
||||
c.Chassis.Model, c.Chassis.Material, c.Chassis.Printed,
|
||||
c.Chassis.WheelbaseMm, c.Chassis.TrackMm,
|
||||
c.Motor.Kind, c.Motor.Class, c.Motor.KV, c.Motor.PowerW,
|
||||
c.Battery.VoltageV, c.Battery.CapacityMah, c.Battery.Cells, c.Battery.Chemistry,
|
||||
string(c.Drive), c.TopSpeedMs, c.ColorHex, c.AvatarURL, c.Active,
|
||||
c.TotalDistanceM, c.TotalRaces, c.TotalLaps, c.BestLapMs,
|
||||
c.CreatedMs, c.UpdatedMs,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCar removes a car by id.
|
||||
func (s *PgStore) DeleteCar(ctx context.Context, id string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM cars WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateCarStats applies a partial mutation to the stats columns. We
|
||||
// first read the row, run the callback, then write the mutated fields
|
||||
// back. This mirrors the in-memory implementation used by tests.
|
||||
func (s *PgStore) UpdateCarStats(ctx context.Context, id string, fn func(*catalog.CarMeta)) error {
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var c catalog.CarMeta
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, name, owner_id, visibility, scale,
|
||||
length_mm, width_mm, height_mm, weight_g,
|
||||
chassis_model, chassis_material, chassis_printed,
|
||||
chassis_wheelbase_mm, chassis_track_mm,
|
||||
motor_kind, motor_class, motor_kv, motor_power_w,
|
||||
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
|
||||
drive, top_speed_ms, color_hex, avatar_url, active,
|
||||
total_distance_m, total_races, total_laps, best_lap_ms,
|
||||
created_ms, updated_ms
|
||||
FROM cars WHERE id = $1 FOR UPDATE`, id,
|
||||
).Scan(
|
||||
&c.ID, &c.Name, &c.OwnerID, &c.Visibility, &c.Scale,
|
||||
&c.LengthMm, &c.WidthMm, &c.HeightMm, &c.WeightG,
|
||||
&c.Chassis.Model, &c.Chassis.Material, &c.Chassis.Printed,
|
||||
&c.Chassis.WheelbaseMm, &c.Chassis.TrackMm,
|
||||
&c.Motor.Kind, &c.Motor.Class, &c.Motor.KV, &c.Motor.PowerW,
|
||||
&c.Battery.VoltageV, &c.Battery.CapacityMah, &c.Battery.Cells, &c.Battery.Chemistry,
|
||||
&c.Drive, &c.TopSpeedMs, &c.ColorHex, &c.AvatarURL, &c.Active,
|
||||
&c.TotalDistanceM, &c.TotalRaces, &c.TotalLaps, &c.BestLapMs,
|
||||
&c.CreatedMs, &c.UpdatedMs,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return catalog.ErrNotFound
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
fn(&c)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE cars SET
|
||||
total_distance_m = $2,
|
||||
total_races = $3,
|
||||
total_laps = $4,
|
||||
best_lap_ms = $5,
|
||||
updated_ms = $6
|
||||
WHERE id = $1`,
|
||||
c.ID, c.TotalDistanceM, c.TotalRaces, c.TotalLaps, c.BestLapMs, c.UpdatedMs,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Package postgres owns the persistence layer of the catalog.
|
||||
//
|
||||
// It exposes a *pgxpool.Pool plus a small set of repository helpers used by
|
||||
// internal/catalog. Migrations are applied at startup from the SQL files in
|
||||
// the migrations/ subdirectory.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// Config captures the connection parameters. URL takes precedence over the
|
||||
// individual fields if non-empty.
|
||||
type Config struct {
|
||||
URL string
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
Database string
|
||||
SSLMode string
|
||||
MaxConns int32
|
||||
MinConns int32
|
||||
ConnectTimeout time.Duration
|
||||
}
|
||||
|
||||
// Open establishes a pgxpool, pings the database and returns it. The
|
||||
// returned pool must be closed by the caller.
|
||||
func Open(ctx context.Context, cfg Config) (*pgxpool.Pool, error) {
|
||||
dsn, err := cfg.DSN()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pcfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dsn: %w", err)
|
||||
}
|
||||
if cfg.MaxConns > 0 {
|
||||
pcfg.MaxConns = cfg.MaxConns
|
||||
}
|
||||
if cfg.MinConns > 0 {
|
||||
pcfg.MinConns = cfg.MinConns
|
||||
}
|
||||
if cfg.ConnectTimeout > 0 {
|
||||
pcfg.ConnConfig.ConnectTimeout = cfg.ConnectTimeout
|
||||
}
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, pcfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := pool.Ping(pingCtx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// DSN builds the postgres connection string.
|
||||
func (c Config) DSN() (string, error) {
|
||||
if c.URL != "" {
|
||||
return c.URL, nil
|
||||
}
|
||||
if c.Host == "" {
|
||||
return "", fmt.Errorf("postgres: host required")
|
||||
}
|
||||
port := c.Port
|
||||
if port == 0 {
|
||||
port = 5432
|
||||
}
|
||||
ssl := c.SSLMode
|
||||
if ssl == "" {
|
||||
ssl = "disable"
|
||||
}
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
|
||||
c.User, c.Password, c.Host, port, c.Database, ssl), nil
|
||||
}
|
||||
|
||||
// Migrate applies any pending SQL migrations from the embedded migrations/
|
||||
// directory. Migrations are tracked in the schema_migrations table and run
|
||||
// in lexical filename order. Each migration runs in its own transaction.
|
||||
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
entries, err := migrationsFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations: %w", err)
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
applied := map[string]struct{}{}
|
||||
rows, err := pool.Query(ctx, `SELECT version FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list applied migrations: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
applied[v] = struct{}{}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, name := range names {
|
||||
if _, ok := applied[name]; ok {
|
||||
continue
|
||||
}
|
||||
body, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", name, err)
|
||||
}
|
||||
if err := applyMigration(ctx, pool, name, string(body)); err != nil {
|
||||
return fmt.Errorf("apply %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyMigration(ctx context.Context, pool *pgxpool.Pool, name, body string) error {
|
||||
tx, err := pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// pgx's simple-protocol Exec() rejects multi-statement strings. Split
|
||||
// the body on ";\n" boundaries and run each statement individually.
|
||||
for i, stmt := range splitStatements(body) {
|
||||
if _, err := tx.Exec(ctx, stmt); err != nil {
|
||||
preview := stmt
|
||||
if len(preview) > 120 {
|
||||
preview = preview[:120] + "..."
|
||||
}
|
||||
return fmt.Errorf("statement %d failed: %w; sql=%q", i+1, err, preview)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO schema_migrations (version) VALUES ($1)`, name); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// splitStatements splits a SQL script on top-level ";" boundaries. It
|
||||
// is deliberately simple: it ignores ";" inside string literals and
|
||||
// comments, which is fine for our migration files (no literal ";"
|
||||
// in any INSERT).
|
||||
func splitStatements(body string) []string {
|
||||
var (
|
||||
out []string
|
||||
buf strings.Builder
|
||||
inStr bool
|
||||
escNext bool
|
||||
)
|
||||
for i := 0; i < len(body); i++ {
|
||||
c := body[i]
|
||||
if escNext {
|
||||
buf.WriteByte(c)
|
||||
escNext = false
|
||||
continue
|
||||
}
|
||||
if c == '\\' && inStr {
|
||||
buf.WriteByte(c)
|
||||
escNext = true
|
||||
continue
|
||||
}
|
||||
if c == '\'' {
|
||||
inStr = !inStr
|
||||
buf.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
if c == '-' && !inStr && i+1 < len(body) && body[i+1] == '-' {
|
||||
// line comment until \n
|
||||
for i < len(body) && body[i] != '\n' {
|
||||
buf.WriteByte(body[i])
|
||||
i++
|
||||
}
|
||||
if i < len(body) {
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c == ';' && !inStr {
|
||||
s := strings.TrimSpace(buf.String())
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
buf.Reset()
|
||||
continue
|
||||
}
|
||||
buf.WriteByte(c)
|
||||
}
|
||||
if rest := strings.TrimSpace(buf.String()); rest != "" {
|
||||
out = append(out, rest)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,836 @@
|
||||
// Package transport defines the wire format between clients and the server.
|
||||
//
|
||||
// PoC: JSON for fast iteration. Specified by proto/client_server.proto for
|
||||
// the production binary protobuf format.
|
||||
//
|
||||
// TODO PoC: replace JSON with protobuf-generated structs (see
|
||||
// ../proto/client_server.proto).
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MessageType identifies the kind of payload.
|
||||
type MessageType string
|
||||
|
||||
const (
|
||||
// Client -> Server.
|
||||
TypeClientHello MessageType = "client_hello"
|
||||
TypeJoinRace MessageType = "join_race"
|
||||
TypeLeaveRace MessageType = "leave_race"
|
||||
TypeInputState MessageType = "input_state"
|
||||
TypeChatMessage MessageType = "chat_message"
|
||||
TypePing MessageType = "ping"
|
||||
|
||||
// Client -> Server (lobby).
|
||||
TypeLobbyList MessageType = "lobby_list"
|
||||
TypeLobbyCreate MessageType = "lobby_create"
|
||||
TypeLobbyQuickPlay MessageType = "lobby_quick_play"
|
||||
TypeLobbyJoinRace MessageType = "lobby_join_race"
|
||||
TypeLobbyLeaveRace MessageType = "lobby_leave_race"
|
||||
TypeLobbyKickDriver MessageType = "lobby_kick_driver"
|
||||
TypeLobbyDelete MessageType = "lobby_delete"
|
||||
|
||||
// Client -> Server (catalog: tracks).
|
||||
TypeTrackList MessageType = "track_list"
|
||||
TypeTrackGet MessageType = "track_get"
|
||||
TypeTrackCreate MessageType = "track_create"
|
||||
TypeTrackUpdate MessageType = "track_update"
|
||||
TypeTrackDelete MessageType = "track_delete"
|
||||
|
||||
// Client -> Server (catalog: cars).
|
||||
TypeCarList MessageType = "car_list"
|
||||
TypeCarGet MessageType = "car_get"
|
||||
TypeCarCreate MessageType = "car_create"
|
||||
TypeCarUpdate MessageType = "car_update"
|
||||
TypeCarDelete MessageType = "car_delete"
|
||||
|
||||
// Client -> Server (stats).
|
||||
TypeStatsRequest MessageType = "stats_request"
|
||||
|
||||
// Server -> Client.
|
||||
TypeServerHello MessageType = "server_hello"
|
||||
TypeRaceSnapshot MessageType = "race_snapshot"
|
||||
TypeInputAck MessageType = "input_ack"
|
||||
TypeCorrection MessageType = "correction"
|
||||
TypeRaceEvent MessageType = "race_event"
|
||||
TypePong MessageType = "pong"
|
||||
TypeError MessageType = "error"
|
||||
|
||||
// Server -> Client (lobby).
|
||||
TypeLobbySnapshot MessageType = "lobby_snapshot"
|
||||
|
||||
// Server -> Client (catalog).
|
||||
TypeTrackSnapshot MessageType = "track_snapshot"
|
||||
TypeTrackAck MessageType = "track_ack"
|
||||
TypeCarSnapshot MessageType = "car_snapshot"
|
||||
TypeCarAck MessageType = "car_ack"
|
||||
TypeCatalogSummary MessageType = "catalog_summary"
|
||||
|
||||
// Server -> Client (stats).
|
||||
TypeStatsSnapshot MessageType = "stats_snapshot"
|
||||
TypeDriverProfile MessageType = "driver_profile"
|
||||
TypeLapRecorded MessageType = "lap_recorded"
|
||||
TypeRaceResult MessageType = "race_result"
|
||||
TypeRaceStandings MessageType = "race_standings"
|
||||
)
|
||||
|
||||
// Envelope wraps every WS frame with a type discriminator.
|
||||
type Envelope struct {
|
||||
Type MessageType `json:"type"`
|
||||
Seq uint64 `json:"seq,omitempty"`
|
||||
TSMs int64 `json:"ts_ms,omitempty"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
// ClientHello is the first message from a client.
|
||||
type ClientHello struct {
|
||||
Version string `json:"version"`
|
||||
Token string `json:"token,omitempty"` // empty in dev mode
|
||||
Locale string `json:"locale,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"` // for reconnect
|
||||
}
|
||||
|
||||
// ServerHello is the response to ClientHello.
|
||||
type ServerHello struct {
|
||||
SessionID string `json:"session_id"`
|
||||
RaceTick uint64 `json:"race_tick"`
|
||||
ServerVersion string `json:"server_version"`
|
||||
Config struct {
|
||||
TickRate int `json:"tick_rate_hz"`
|
||||
SnapshotRate int `json:"snapshot_rate_hz"`
|
||||
MaxCars int `json:"max_cars"`
|
||||
} `json:"config"`
|
||||
}
|
||||
|
||||
// JoinRace is sent by the client to enter a race session.
|
||||
type JoinRace struct {
|
||||
RaceID string `json:"race_id"`
|
||||
CarSlot int `json:"car_slot"` // 0-3 for PoC (4 cars)
|
||||
CarName string `json:"car_name,omitempty"`
|
||||
}
|
||||
|
||||
// LeaveRace is sent when leaving a race.
|
||||
type LeaveRace struct {
|
||||
RaceID string `json:"race_id"`
|
||||
}
|
||||
|
||||
// InputState is the high-rate control packet (60-120 Hz).
|
||||
type InputState struct {
|
||||
Steering float64 `json:"steering"` // -1.0 .. 1.0
|
||||
Throttle float64 `json:"throttle"` // 0.0 .. 1.0
|
||||
Brake float64 `json:"brake"` // 0.0 .. 1.0
|
||||
Gear int `json:"gear"` // -1 reverse, 0 neutral, 1..6 forward
|
||||
Buttons uint32 `json:"buttons,omitempty"`
|
||||
}
|
||||
|
||||
// ChatMessage for lobby chat (rate-limited).
|
||||
type ChatMessage struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Ping for latency measurement.
|
||||
type Ping struct {
|
||||
ClientTSMs int64 `json:"client_ts_ms"`
|
||||
}
|
||||
|
||||
// RaceSnapshot is the authoritative state of the race, broadcast N times/sec.
|
||||
type RaceSnapshot struct {
|
||||
Tick uint64 `json:"tick"`
|
||||
TSMs int64 `json:"ts_ms"`
|
||||
Elapsed float64 `json:"elapsed_s"`
|
||||
Cars []CarInfo `json:"cars"`
|
||||
}
|
||||
|
||||
// CarInfo describes one car in the race.
|
||||
type CarInfo struct {
|
||||
ID string `json:"id"`
|
||||
DriverName string `json:"driver_name"`
|
||||
X float64 `json:"x"` // metres
|
||||
Y float64 `json:"y"` // metres
|
||||
Heading float64 `json:"heading"` // radians
|
||||
Speed float64 `json:"speed"` // m/s
|
||||
Lap int `json:"lap"`
|
||||
Sector int `json:"sector"` // 0..2
|
||||
LastLapMs int64 `json:"last_lap_ms"`
|
||||
BestLapMs int64 `json:"best_lap_ms"`
|
||||
InputApplied struct {
|
||||
Steering float64 `json:"steering"`
|
||||
Throttle float64 `json:"throttle"`
|
||||
Brake float64 `json:"brake"`
|
||||
} `json:"input_applied"`
|
||||
DNF bool `json:"dnf"`
|
||||
}
|
||||
|
||||
// InputAck acknowledges an input packet (lightweight).
|
||||
type InputAck struct {
|
||||
Seq uint64 `json:"seq"`
|
||||
AppliedAtMs int64 `json:"applied_at_ms"`
|
||||
ServerTickMs int64 `json:"server_tick_ms"`
|
||||
}
|
||||
|
||||
// Correction is sent when client prediction diverged from server.
|
||||
type Correction struct {
|
||||
Tick uint64 `json:"tick"`
|
||||
DeltaSteering float64 `json:"delta_steering"`
|
||||
DeltaThrottle float64 `json:"delta_throttle"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// RaceEvent for major state transitions.
|
||||
type RaceEvent struct {
|
||||
RaceID string `json:"race_id"`
|
||||
Event string `json:"event"` // "start", "lap", "sector", "dnf", "finish"
|
||||
CarID string `json:"car_id,omitempty"`
|
||||
Sector int `json:"sector,omitempty"`
|
||||
LapMs int64 `json:"lap_ms,omitempty"`
|
||||
TSMs int64 `json:"ts_ms"`
|
||||
}
|
||||
|
||||
// Pong for RTT measurement.
|
||||
type Pong struct {
|
||||
ClientTSMs int64 `json:"client_ts_ms"`
|
||||
ServerTSMs int64 `json:"server_ts_ms"`
|
||||
}
|
||||
|
||||
// ErrorMsg is sent on protocol or validation errors.
|
||||
type ErrorMsg struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lobby payloads
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// LobbyRace is the wire form of lobby.RaceMeta (kept separate to avoid
|
||||
// a circular import between transport and lobby).
|
||||
type LobbyRace struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TrackID string `json:"track_id"`
|
||||
MaxCars int `json:"max_cars"`
|
||||
Laps int `json:"laps"`
|
||||
TimeLimitS int `json:"time_limit_s"`
|
||||
DriverIDs []string `json:"driver_ids"`
|
||||
Status string `json:"status"`
|
||||
CreatedMs int64 `json:"created_ms"`
|
||||
StartedMs int64 `json:"started_ms,omitempty"`
|
||||
// Podium is populated only for finished races. Empty for live ones.
|
||||
Podium []RacePodiumEntry `json:"podium,omitempty"`
|
||||
}
|
||||
|
||||
// RacePodiumEntry is one row of a finished race's top-3.
|
||||
type RacePodiumEntry struct {
|
||||
Position int `json:"position" example:"1"`
|
||||
DriverID string `json:"driver_id" example:"driver-alice"`
|
||||
Name string `json:"name" example:"driver-alice"`
|
||||
TotalTimeMs int64 `json:"total_time_ms" example:"123456"`
|
||||
}
|
||||
|
||||
// LobbyDriver is the wire form of lobby.DriverMeta.
|
||||
type LobbyDriver struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // idle | racing | offline
|
||||
RaceID string `json:"race_id,omitempty"`
|
||||
ConnectedMs int64 `json:"connected_ms"`
|
||||
LastSeenMs int64 `json:"last_seen_ms"`
|
||||
Country string `json:"country,omitempty"`
|
||||
AvatarHue int `json:"avatar_hue,omitempty"`
|
||||
}
|
||||
|
||||
// LobbySnapshot is the full lobby state, broadcast on change.
|
||||
type LobbySnapshot struct {
|
||||
GeneratedMs int64 `json:"generated_ms"`
|
||||
Version uint64 `json:"version"`
|
||||
Races []LobbyRace `json:"races"`
|
||||
Drivers []LobbyDriver `json:"drivers"`
|
||||
}
|
||||
|
||||
// LobbyCreate is sent by a client to create a race (becomes host).
|
||||
type LobbyCreate struct {
|
||||
RaceID string `json:"race_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
TrackID string `json:"track_id,omitempty"`
|
||||
MaxCars int `json:"max_cars,omitempty"`
|
||||
Laps int `json:"laps,omitempty"`
|
||||
TimeLimitS int `json:"time_limit_s,omitempty"`
|
||||
}
|
||||
|
||||
// LobbyJoinRace is sent by a client to join a specific race.
|
||||
type LobbyJoinRace struct {
|
||||
RaceID string `json:"race_id"`
|
||||
CarSlot int `json:"car_slot,omitempty"`
|
||||
CarName string `json:"car_name,omitempty"`
|
||||
}
|
||||
|
||||
// LobbyLeaveRace moves a driver from race back to lobby.
|
||||
type LobbyLeaveRace struct {
|
||||
RaceID string `json:"race_id,omitempty"`
|
||||
}
|
||||
|
||||
// LobbyKickDriver is sent by the host to remove another driver.
|
||||
type LobbyKickDriver struct {
|
||||
RaceID string `json:"race_id"`
|
||||
DriverID string `json:"driver_id"`
|
||||
}
|
||||
|
||||
// LobbyDelete removes the race entirely (host only).
|
||||
type LobbyDelete struct {
|
||||
RaceID string `json:"race_id"`
|
||||
}
|
||||
|
||||
// LobbyCreatedAck confirms a successful lobby_create.
|
||||
type LobbyCreatedAck struct {
|
||||
Ok bool `json:"ok"`
|
||||
Race LobbyRace `json:"race,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Driver LobbyDriver `json:"driver,omitempty"`
|
||||
}
|
||||
|
||||
// LobbyJoinAck confirms lobby_join_race / lobby_quick_play.
|
||||
type LobbyJoinAck struct {
|
||||
Ok bool `json:"ok"`
|
||||
Race LobbyRace `json:"race,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Catalog payloads (tracks and cars)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// TrackWaypoint is the wire form of catalog.Waypoint.
|
||||
type TrackWaypoint struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
HeadingRad float64 `json:"heading_rad"`
|
||||
SpeedMs float64 `json:"speed_ms"`
|
||||
Curvature float64 `json:"curvature"`
|
||||
}
|
||||
|
||||
// TrackBounds is the wire form of catalog.Bounds.
|
||||
type TrackBounds 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"`
|
||||
WidthM float64 `json:"width_m"`
|
||||
}
|
||||
|
||||
// TrackWire is the wire form of catalog.TrackMeta.
|
||||
type TrackWire struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AuthorID string `json:"author_id"`
|
||||
Visibility string `json:"visibility"`
|
||||
Scale int `json:"scale"`
|
||||
LengthM float64 `json:"length_m"`
|
||||
WidthM float64 `json:"width_m"`
|
||||
LaneWidthM float64 `json:"lane_width_m"`
|
||||
Bounds TrackBounds `json:"bounds"`
|
||||
Surface string `json:"surface"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Centerline []TrackWaypoint `json:"centerline"`
|
||||
BestLapMs int64 `json:"best_lap_ms"`
|
||||
BestLapHolder string `json:"best_lap_holder,omitempty"`
|
||||
CreatedMs int64 `json:"created_ms"`
|
||||
UpdatedMs int64 `json:"updated_ms"`
|
||||
}
|
||||
|
||||
// TrackListRequest: payload { "scope": "all|system|public|private", "tag": "..." }.
|
||||
type TrackListRequest struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
}
|
||||
|
||||
// TrackGetRequest: payload { "id": "..." }.
|
||||
type TrackGetRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// TrackCreateRequest: full TrackWire (server fills ID if empty).
|
||||
type TrackCreateRequest struct {
|
||||
Track TrackWire `json:"track"`
|
||||
}
|
||||
|
||||
// TrackUpdateRequest: ID + patch fields.
|
||||
type TrackUpdateRequest struct {
|
||||
ID string `json:"id"`
|
||||
Patch TrackPatch `json:"patch"`
|
||||
}
|
||||
|
||||
// TrackPatch: any field may be omitted (empty string / zero means unchanged).
|
||||
type TrackPatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Visibility *string `json:"visibility,omitempty"`
|
||||
Scale *int `json:"scale,omitempty"`
|
||||
LengthM *float64 `json:"length_m,omitempty"`
|
||||
WidthM *float64 `json:"width_m,omitempty"`
|
||||
LaneWidthM *float64 `json:"lane_width_m,omitempty"`
|
||||
Surface *string `json:"surface,omitempty"`
|
||||
Tags *[]string `json:"tags,omitempty"`
|
||||
Centerline *[]TrackWaypoint `json:"centerline,omitempty"`
|
||||
}
|
||||
|
||||
// TrackDeleteRequest: payload { "id": "..." }.
|
||||
type TrackDeleteRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// TrackSnapshot: full catalog tracks feed (broadcast on change).
|
||||
type TrackSnapshot struct {
|
||||
GeneratedMs int64 `json:"generated_ms"`
|
||||
Version uint64 `json:"version"`
|
||||
Tracks []TrackWire `json:"tracks"`
|
||||
}
|
||||
|
||||
// TrackAck: response to create/update/delete operations.
|
||||
type TrackAck struct {
|
||||
Ok bool `json:"ok"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Track TrackWire `json:"track,omitempty"`
|
||||
}
|
||||
|
||||
// MotorWire is the wire form of catalog.MotorSpec.
|
||||
type MotorWire struct {
|
||||
Kind string `json:"kind"`
|
||||
Class string `json:"class"`
|
||||
KV int `json:"kv"`
|
||||
PowerW float64 `json:"power_w"`
|
||||
}
|
||||
|
||||
// BatteryWire is the wire form of catalog.BatterySpec.
|
||||
type BatteryWire struct {
|
||||
VoltageV float64 `json:"voltage_v"`
|
||||
CapacityMah int `json:"capacity_mah"`
|
||||
Cells int `json:"cells"`
|
||||
Chemistry string `json:"chemistry"`
|
||||
}
|
||||
|
||||
// ChassisWire is the wire form of catalog.ChassisSpec.
|
||||
type ChassisWire struct {
|
||||
Model string `json:"model"`
|
||||
Material string `json:"material"`
|
||||
Printed bool `json:"printed"`
|
||||
WheelbaseMm float64 `json:"wheelbase_mm"`
|
||||
TrackMm float64 `json:"track_mm"`
|
||||
}
|
||||
|
||||
// CarWire is the wire form of catalog.CarMeta.
|
||||
type CarWire struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Visibility string `json:"visibility"`
|
||||
Scale int `json:"scale"`
|
||||
|
||||
LengthMm float64 `json:"length_mm"`
|
||||
WidthMm float64 `json:"width_mm"`
|
||||
HeightMm float64 `json:"height_mm"`
|
||||
WeightG float64 `json:"weight_g"`
|
||||
|
||||
Chassis ChassisWire `json:"chassis"`
|
||||
Motor MotorWire `json:"motor"`
|
||||
Battery BatteryWire `json:"battery"`
|
||||
Drive string `json:"drive"`
|
||||
|
||||
TopSpeedMs float64 `json:"top_speed_ms"`
|
||||
|
||||
ColorHex string `json:"color_hex"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
|
||||
Active bool `json:"active"`
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// CarListRequest: payload { "scope": "all|system|public|private", "owner_id": "..." }.
|
||||
type CarListRequest struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
}
|
||||
|
||||
// CarGetRequest: payload { "id": "..." }.
|
||||
type CarGetRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// CarCreateRequest: full CarWire (server fills ID if empty).
|
||||
type CarCreateRequest struct {
|
||||
Car CarWire `json:"car"`
|
||||
}
|
||||
|
||||
// CarUpdateRequest: ID + patch fields.
|
||||
type CarUpdateRequest struct {
|
||||
ID string `json:"id"`
|
||||
Patch CarPatch `json:"patch"`
|
||||
}
|
||||
|
||||
// CarPatch: any field may be omitted (nil pointer = unchanged).
|
||||
type CarPatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Visibility *string `json:"visibility,omitempty"`
|
||||
Scale *int `json:"scale,omitempty"`
|
||||
LengthMm *float64 `json:"length_mm,omitempty"`
|
||||
WidthMm *float64 `json:"width_mm,omitempty"`
|
||||
HeightMm *float64 `json:"height_mm,omitempty"`
|
||||
WeightG *float64 `json:"weight_g,omitempty"`
|
||||
Chassis *ChassisWire `json:"chassis,omitempty"`
|
||||
Motor *MotorWire `json:"motor,omitempty"`
|
||||
Battery *BatteryWire `json:"battery,omitempty"`
|
||||
Drive *string `json:"drive,omitempty"`
|
||||
TopSpeedMs *float64 `json:"top_speed_ms,omitempty"`
|
||||
ColorHex *string `json:"color_hex,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
}
|
||||
|
||||
// CarDeleteRequest: payload { "id": "..." }.
|
||||
type CarDeleteRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// CarSnapshot: full catalog cars feed (broadcast on change).
|
||||
type CarSnapshot struct {
|
||||
GeneratedMs int64 `json:"generated_ms"`
|
||||
Version uint64 `json:"version"`
|
||||
Cars []CarWire `json:"cars"`
|
||||
}
|
||||
|
||||
// CarAck: response to create/update/delete operations.
|
||||
type CarAck struct {
|
||||
Ok bool `json:"ok"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Car CarWire `json:"car,omitempty"`
|
||||
}
|
||||
|
||||
// CatalogSummary: lightweight counters (for header chips).
|
||||
type CatalogSummary 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"`
|
||||
TSMs int64 `json:"ts_ms"`
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Races: list / upcoming / queue / plans
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// RaceListResponse is the keyset-paginated body returned by /api/races.
|
||||
type RaceListResponse struct {
|
||||
Items []LobbyRace `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty" example:"MTcwMDAwMDAwMDAwMDpyYWNlLWFiYzpmaW5pc2hlZA"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// RaceUpcomingItem is one row in the upcoming list.
|
||||
type RaceUpcomingItem struct {
|
||||
Meta LobbyRace `json:"meta"`
|
||||
QueueLen int `json:"queue_len"`
|
||||
PlanID string `json:"plan_id,omitempty"`
|
||||
StartAtMs int64 `json:"start_at_ms"`
|
||||
}
|
||||
|
||||
// RaceUpcomingResponse is the body returned by /api/races/upcoming.
|
||||
type RaceUpcomingResponse struct {
|
||||
Items []RaceUpcomingItem `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// RaceQueueEntry is one (driver, race) subscription.
|
||||
type RaceQueueEntry struct {
|
||||
DriverID string `json:"driver_id" example:"driver-42"`
|
||||
RaceID string `json:"race_id" example:"race-1730000-7"`
|
||||
PlanID string `json:"plan_id,omitempty" example:"plan-1730000-1"`
|
||||
EnqueuedMs int64 `json:"enqueued_ms" example:"1730000000000"`
|
||||
}
|
||||
|
||||
// RaceQueueJoinRequest is the body of POST /api/races/queue/join.
|
||||
// Either driver_id (body) or X-Driver-Id header is accepted.
|
||||
type RaceQueueJoinRequest struct {
|
||||
DriverID string `json:"driver_id" example:"driver-42"`
|
||||
Limit int `json:"limit,omitempty" example:"3"`
|
||||
}
|
||||
|
||||
// RaceQueueJoinResponse is what /api/races/queue/join returns.
|
||||
type RaceQueueJoinResponse struct {
|
||||
Joined []RaceQueueEntry `json:"joined"`
|
||||
Skipped []string `json:"skipped,omitempty" example:"race-1,race-2"`
|
||||
}
|
||||
|
||||
// RaceQueueListResponse is what GET /api/races/queue returns.
|
||||
type RaceQueueListResponse struct {
|
||||
Items []RaceQueueEntry `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// RacePlan is the wire form of races.RacePlan.
|
||||
type RacePlan struct {
|
||||
ID string `json:"id" example:"plan-1730000-1"`
|
||||
Name string `json:"name" example:"Daily Monza"`
|
||||
TrackID string `json:"track_id" example:"default"`
|
||||
MaxCars int `json:"max_cars" example:"4"`
|
||||
Laps int `json:"laps" example:"10"`
|
||||
TimeLimitS int `json:"time_limit_s,omitempty"`
|
||||
StartAtMs int64 `json:"start_at_ms" example:"1730000000000"`
|
||||
IntervalS int `json:"interval_s,omitempty" example:"3600"`
|
||||
Count int `json:"count,omitempty" example:"0"`
|
||||
Enabled bool `json:"enabled" example:"true"`
|
||||
CreatedMs int64 `json:"created_ms"`
|
||||
UpdatedMs int64 `json:"updated_ms"`
|
||||
NextFireMs int64 `json:"next_fire_ms"`
|
||||
FiresDone int `json:"fires_done"`
|
||||
}
|
||||
|
||||
// RacePlanCreateRequest is the body of POST /api/races/plans.
|
||||
type RacePlanCreateRequest struct {
|
||||
ID string `json:"id,omitempty" example:"plan-1730000-1"`
|
||||
Name string `json:"name" example:"Daily Monza"`
|
||||
TrackID string `json:"track_id,omitempty" example:"default"`
|
||||
MaxCars int `json:"max_cars" example:"4"`
|
||||
Laps int `json:"laps,omitempty" example:"10"`
|
||||
TimeLimitS int `json:"time_limit_s,omitempty"`
|
||||
StartAtMs int64 `json:"start_at_ms" example:"1730000000000"`
|
||||
IntervalS int `json:"interval_s,omitempty" example:"3600"`
|
||||
Count int `json:"count,omitempty" example:"0"`
|
||||
Enabled *bool `json:"enabled,omitempty" example:"true"`
|
||||
}
|
||||
|
||||
// RacePlanListResponse is the keyset-paginated body returned by GET /api/races/plans.
|
||||
type RacePlanListResponse struct {
|
||||
Items []RacePlan `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Drivers and clans
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Driver is the wire form of a driver record.
|
||||
type Driver struct {
|
||||
ID string `json:"id" example:"driver-1782000-123"`
|
||||
Nickname string `json:"nickname" example:"ACE"`
|
||||
Name string `json:"name" example:"Alice"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/u/ace.png"`
|
||||
ClanID string `json:"clan_id,omitempty" example:"clan-1782000-1"`
|
||||
CreatedMs int64 `json:"created_ms"`
|
||||
UpdatedMs int64 `json:"updated_ms"`
|
||||
}
|
||||
|
||||
// DriverCreateRequest is the body of POST /api/drivers.
|
||||
type DriverCreateRequest struct {
|
||||
ID string `json:"id,omitempty" example:"driver-1782000-123"`
|
||||
Nickname string `json:"nickname" example:"ACE"`
|
||||
Name string `json:"name" example:"Alice"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/u/ace.png"`
|
||||
ClanID string `json:"clan_id,omitempty" example:"clan-1782000-1"`
|
||||
}
|
||||
|
||||
// DriverUpdateRequest is the body of PUT /api/drivers/{id}.
|
||||
type DriverUpdateRequest struct {
|
||||
Name string `json:"name,omitempty" example:"Alice"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/u/ace.png"`
|
||||
ClanID *string `json:"clan_id,omitempty" example:"clan-1782000-1"`
|
||||
}
|
||||
|
||||
// DriverListResponse is the body returned by GET /api/drivers.
|
||||
type DriverListResponse struct {
|
||||
Items []Driver `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// Clan is the wire form of a clan record.
|
||||
type Clan struct {
|
||||
ID string `json:"id" example:"clan-1782000-1"`
|
||||
Tag string `json:"tag" example:"ACE"`
|
||||
Name string `json:"name" example:"Ace Racing"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/c/ace.png"`
|
||||
CreatedMs int64 `json:"created_ms"`
|
||||
UpdatedMs int64 `json:"updated_ms"`
|
||||
}
|
||||
|
||||
// ClanCreateRequest is the body of POST /api/clans.
|
||||
type ClanCreateRequest struct {
|
||||
ID string `json:"id,omitempty" example:"clan-1782000-1"`
|
||||
Tag string `json:"tag" example:"ACE"`
|
||||
Name string `json:"name" example:"Ace Racing"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/c/ace.png"`
|
||||
}
|
||||
|
||||
// ClanUpdateRequest is the body of PUT /api/clans/{id}.
|
||||
type ClanUpdateRequest struct {
|
||||
Name string `json:"name,omitempty" example:"Ace Racing"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/c/ace.png"`
|
||||
}
|
||||
|
||||
// ClanListResponse is the body returned by GET /api/clans.
|
||||
type ClanListResponse struct {
|
||||
Items []Clan `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Stats payloads
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// StatsServer mirrors stats.ServerStats for the wire.
|
||||
type StatsServer struct {
|
||||
StartedMs int64 `json:"started_ms"`
|
||||
UptimeMs int64 `json:"uptime_ms"`
|
||||
CurrentClients int64 `json:"current_clients"`
|
||||
PeakClients int64 `json:"peak_clients"`
|
||||
TotalConnections uint64 `json:"total_connections"`
|
||||
TotalDisconnects uint64 `json:"total_disconnects"`
|
||||
TotalRacesCreated uint64 `json:"total_races_created"`
|
||||
TotalRacesStarted uint64 `json:"total_races_started"`
|
||||
TotalRacesFinished uint64 `json:"total_races_finished"`
|
||||
TotalLapsRecorded uint64 `json:"total_laps_recorded"`
|
||||
TotalInputsRecv uint64 `json:"total_inputs_received"`
|
||||
TotalSnapshotsSent uint64 `json:"total_snapshots_sent"`
|
||||
SnapshotsDropped uint64 `json:"snapshots_dropped"`
|
||||
AvgRTTMs float64 `json:"avg_rtt_ms"`
|
||||
}
|
||||
|
||||
// StatsDriver mirrors stats.DriverProfile for the wire.
|
||||
type StatsDriver struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TotalRacesStarted uint64 `json:"total_races_started"`
|
||||
TotalRacesFinished uint64 `json:"total_races_finished"`
|
||||
TotalLaps uint64 `json:"total_laps"`
|
||||
BestLapMs int64 `json:"best_lap_ms"`
|
||||
LastLapMs int64 `json:"last_lap_ms"`
|
||||
TotalDistanceM float64 `json:"total_distance_m"`
|
||||
TotalPlaytimeMs int64 `json:"total_playtime_ms"`
|
||||
Wins uint64 `json:"wins"`
|
||||
Podiums uint64 `json:"podiums"`
|
||||
LastSeenMs int64 `json:"last_seen_ms"`
|
||||
}
|
||||
|
||||
// StatsLap is one lap in the recent-laps feed.
|
||||
type StatsLap struct {
|
||||
RaceID string `json:"race_id"`
|
||||
DriverID string `json:"driver_id"`
|
||||
LapMs int64 `json:"lap_ms"`
|
||||
Sector1Ms int64 `json:"sector1_ms"`
|
||||
Sector2Ms int64 `json:"sector2_ms"`
|
||||
Sector3Ms int64 `json:"sector3_ms"`
|
||||
Position int `json:"position,omitempty"`
|
||||
TSMs int64 `json:"ts_ms"`
|
||||
}
|
||||
|
||||
// StatsRaceResult is the final classification of a race for one driver.
|
||||
type StatsRaceResult struct {
|
||||
RaceID string `json:"race_id"`
|
||||
DriverID string `json:"driver_id"`
|
||||
FinishedAtMs int64 `json:"finished_at_ms"`
|
||||
Position int `json:"position"`
|
||||
TotalLaps int `json:"total_laps"`
|
||||
BestLapMs int64 `json:"best_lap_ms"`
|
||||
TotalTimeMs int64 `json:"total_time_ms"`
|
||||
DNF bool `json:"dnf"`
|
||||
}
|
||||
|
||||
// StatsSnapshot is the full server-side stats payload.
|
||||
type StatsSnapshot struct {
|
||||
Server StatsServer `json:"server"`
|
||||
Drivers []StatsDriver `json:"drivers"`
|
||||
Laps []StatsLap `json:"laps"`
|
||||
Results []StatsRaceResult `json:"results"`
|
||||
TSMs int64 `json:"ts_ms"`
|
||||
}
|
||||
|
||||
// StatsRequest is sent by clients to request a stats snapshot.
|
||||
// Scope: "server" | "driver" | "race".
|
||||
type StatsRequest struct {
|
||||
Scope string `json:"scope"`
|
||||
TargetID string `json:"target_id,omitempty"`
|
||||
}
|
||||
|
||||
// RaceStanding is one row in mid-race standings.
|
||||
type RaceStanding struct {
|
||||
Position int `json:"position"`
|
||||
CarID string `json:"car_id"`
|
||||
DriverID string `json:"driver_id"`
|
||||
DriverName string `json:"driver_name"`
|
||||
Lap int `json:"lap"`
|
||||
Sector int `json:"sector"`
|
||||
BestLapMs int64 `json:"best_lap_ms"`
|
||||
LastLapMs int64 `json:"last_lap_ms"`
|
||||
GapMs int64 `json:"gap_ms"`
|
||||
DNF bool `json:"dnf"`
|
||||
}
|
||||
|
||||
// RaceStandingsMsg is broadcast periodically during a race.
|
||||
type RaceStandingsMsg struct {
|
||||
RaceID string `json:"race_id"`
|
||||
Tick uint64 `json:"tick"`
|
||||
TSMs int64 `json:"ts_ms"`
|
||||
Lap int `json:"lap"` // leader lap
|
||||
Elapsed float64 `json:"elapsed_s"`
|
||||
Rows []RaceStanding `json:"rows"`
|
||||
}
|
||||
|
||||
// LapRecordedMsg is sent when a driver completes a lap.
|
||||
type LapRecordedMsg struct {
|
||||
RaceID string `json:"race_id"`
|
||||
DriverID string `json:"driver_id"`
|
||||
Lap int `json:"lap"`
|
||||
LapMs int64 `json:"lap_ms"`
|
||||
Sector1Ms int64 `json:"sector1_ms"`
|
||||
Sector2Ms int64 `json:"sector2_ms"`
|
||||
Sector3Ms int64 `json:"sector3_ms"`
|
||||
TSMs int64 `json:"ts_ms"`
|
||||
Position int `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
// RaceResultMsg is sent for each driver when a race finishes.
|
||||
type RaceResultMsg struct {
|
||||
RaceID string `json:"race_id"`
|
||||
DriverID string `json:"driver_id"`
|
||||
Position int `json:"position"`
|
||||
TotalLaps int `json:"total_laps"`
|
||||
BestLapMs int64 `json:"best_lap_ms"`
|
||||
TotalTimeMs int64 `json:"total_time_ms"`
|
||||
DNF bool `json:"dnf"`
|
||||
FinishedAtMs int64 `json:"finished_at_ms"`
|
||||
}
|
||||
|
||||
// Encode marshals an envelope to JSON bytes.
|
||||
func Encode(env *Envelope) ([]byte, error) {
|
||||
if env.TSMs == 0 {
|
||||
env.TSMs = time.Now().UnixMilli()
|
||||
}
|
||||
return json.Marshal(env)
|
||||
}
|
||||
|
||||
// Decode unmarshals JSON bytes into an envelope (payload kept as json.RawMessage).
|
||||
func Decode(data []byte) (*Envelope, error) {
|
||||
var env Envelope
|
||||
if err := json.Unmarshal(data, &env); err != nil {
|
||||
return nil, fmt.Errorf("decode envelope: %w", err)
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
Reference in New Issue
Block a user