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
|
||||
}
|
||||
Reference in New Issue
Block a user