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 }