Files
x0gp/server/internal/catalog/catalog.go
T

650 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// GetTrackCalendar returns all track calendar entries from the store.
func (s *Service) GetTrackCalendar(ctx context.Context) ([]TrackCalendarEntry, error) {
if s.store == nil {
return nil, ErrStoreMissing
}
return s.store.GetTrackCalendar(ctx)
}
// CreateTrackCalendar creates a new calendar entry.
func (s *Service) CreateTrackCalendar(ctx context.Context, trackID string, startAt, endAt time.Time) (TrackCalendarEntry, error) {
if s.store == nil {
return TrackCalendarEntry{}, ErrStoreMissing
}
s.mu.RLock()
_, trackExists := s.tracks[TrackID(trackID)]
s.mu.RUnlock()
if !trackExists {
return TrackCalendarEntry{}, fmt.Errorf("%w: track %s does not exist", ErrNotFound, trackID)
}
if startAt.After(endAt) || startAt.Equal(endAt) {
return TrackCalendarEntry{}, fmt.Errorf("%w: start time must be before end time", ErrInvalidInput)
}
entry := TrackCalendarEntry{
TrackID: trackID,
StartAt: startAt,
EndAt: endAt,
}
return s.store.CreateTrackCalendar(ctx, entry)
}
// DeleteTrackCalendar deletes a calendar entry.
func (s *Service) DeleteTrackCalendar(ctx context.Context, id int) error {
if s.store == nil {
return ErrStoreMissing
}
return s.store.DeleteTrackCalendar(ctx, id)
}