Files
x0gp/server/internal/catalog/catalog.go
T
x0gp 978d36c505 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.
2026-06-22 22:01:09 +04:00

609 lines
15 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
}