Files
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

200 lines
6.8 KiB
Go

// Package main contains the genseed utility. It is built into a standalone
// binary via `go build ./scripts/genseed` and is not part of the runtime.
//
// Usage:
//
// go run ./scripts/genseed seeds.json > 002_seed.sql
//
// It reads the JSON dump produced by `DUMP_SEEDS=1 go test ./internal/catalog`
// and emits ON CONFLICT-safe INSERTs for the catalog tables.
package main
import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
)
type waypoint struct {
X float64 `json:"x"`
Y float64 `json:"y"`
HeadingRad float64 `json:"heading_rad"`
SpeedMs float64 `json:"speed_ms"`
Curvature float64 `json:"curvature"`
}
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"`
WidthM float64 `json:"width_m"`
}
type track struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
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 bounds `json:"bounds"`
Surface string `json:"surface"`
Tags []string `json:"tags"`
Centerline []waypoint `json:"centerline"`
BestLapMs int64 `json:"best_lap_ms"`
BestLapHolder string `json:"best_lap_holder"`
CreatedMs int64 `json:"created_ms"`
UpdatedMs int64 `json:"updated_ms"`
}
type chassis struct {
Model string `json:"model"`
Material string `json:"material"`
Printed bool `json:"printed"`
WheelbaseMm float64 `json:"wheelbase_mm"`
TrackMm float64 `json:"track_mm"`
}
type motor struct {
Kind string `json:"kind"`
Class string `json:"class"`
KV int `json:"kv"`
PowerW float64 `json:"power_w"`
}
type battery struct {
VoltageV float64 `json:"voltage_v"`
CapacityMah int `json:"capacity_mah"`
Cells int `json:"cells"`
Chemistry string `json:"chemistry"`
}
type car 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 chassis `json:"chassis"`
Motor motor `json:"motor"`
Battery battery `json:"battery"`
Drive string `json:"drive"`
TopSpeedMs float64 `json:"top_speed_ms"`
ColorHex string `json:"color_hex"`
AvatarURL string `json:"avatar_url"`
Active bool `json:"active"`
CreatedMs int64 `json:"created_ms"`
UpdatedMs int64 `json:"updated_ms"`
}
type dump struct {
Tracks []track `json:"tracks"`
Cars []car `json:"cars"`
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: genseed <seeds.json>")
os.Exit(2)
}
f, err := os.Open(os.Args[1])
if err != nil {
panic(err)
}
defer f.Close()
var d dump
if err := json.NewDecoder(f).Decode(&d); err != nil {
panic(err)
}
var b strings.Builder
b.WriteString("-- 002_seed.sql — initial system catalog (tracks + cars).\n")
b.WriteString("-- Generated from internal/catalog/seeddefs via scripts/genseed.\n")
b.WriteString("-- Idempotent: safe to re-apply (ON CONFLICT DO NOTHING).\n\n")
for _, t := range d.Tracks {
fmt.Fprintf(&b, "INSERT INTO tracks (id, name, description, author_id, visibility, scale,\n")
fmt.Fprintf(&b, " length_m, width_m, lane_width_m, surface, best_lap_ms, best_lap_holder,\n")
fmt.Fprintf(&b, " bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,\n")
fmt.Fprintf(&b, " created_ms, updated_ms)\n")
fmt.Fprintf(&b, "VALUES (%s, %s, %s, %s, %s, %d,\n",
sqlStr(t.ID), sqlStr(t.Name), sqlStr(t.Description), sqlStr(t.AuthorID),
sqlStr(t.Visibility), t.Scale)
fmt.Fprintf(&b, " %s, %s, %s, %s, %d, %s,\n",
sqlF(t.LengthM), sqlF(t.WidthM), sqlF(t.LaneWidthM), sqlStr(t.Surface),
t.BestLapMs, sqlStr(t.BestLapHolder))
fmt.Fprintf(&b, " %s, %s, %s, %s,\n",
sqlF(t.Bounds.MinX), sqlF(t.Bounds.MinY), sqlF(t.Bounds.MaxX), sqlF(t.Bounds.MaxY))
fmt.Fprintf(&b, " %d, %d)\n", t.CreatedMs, t.UpdatedMs)
b.WriteString("ON CONFLICT (id) DO NOTHING;\n\n")
for i, wp := range t.Centerline {
fmt.Fprintf(&b, "INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)\n")
fmt.Fprintf(&b, "VALUES (%s, %d, %s, %s, %s, %s, %s)\n",
sqlStr(t.ID), i,
sqlF(wp.X), sqlF(wp.Y), sqlF(wp.HeadingRad), sqlF(wp.SpeedMs), sqlF(wp.Curvature))
b.WriteString("ON CONFLICT (track_id, seq) DO NOTHING;\n")
}
b.WriteString("\n")
for _, tag := range t.Tags {
fmt.Fprintf(&b, "INSERT INTO track_tags (track_id, tag) VALUES (%s, %s)\n",
sqlStr(t.ID), sqlStr(tag))
b.WriteString("ON CONFLICT DO NOTHING;\n")
}
b.WriteString("\n")
}
for _, c := range d.Cars {
fmt.Fprintf(&b, "INSERT INTO cars (id, name, owner_id, visibility, scale,\n")
fmt.Fprintf(&b, " length_mm, width_mm, height_mm, weight_g,\n")
fmt.Fprintf(&b, " chassis_model, chassis_material, chassis_printed, chassis_wheelbase_mm, chassis_track_mm,\n")
fmt.Fprintf(&b, " motor_kind, motor_class, motor_kv, motor_power_w,\n")
fmt.Fprintf(&b, " battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,\n")
fmt.Fprintf(&b, " drive, top_speed_ms, color_hex, avatar_url, active,\n")
fmt.Fprintf(&b, " created_ms, updated_ms)\n")
fmt.Fprintf(&b, "VALUES (%s, %s, %s, %s, %d,\n",
sqlStr(c.ID), sqlStr(c.Name), sqlStr(c.OwnerID), sqlStr(c.Visibility), c.Scale)
fmt.Fprintf(&b, " %s, %s, %s, %s,\n",
sqlF(c.LengthMm), sqlF(c.WidthMm), sqlF(c.HeightMm), sqlF(c.WeightG))
fmt.Fprintf(&b, " %s, %s, %s, %s, %s,\n",
sqlStr(c.Chassis.Model), sqlStr(c.Chassis.Material), sqlBool(c.Chassis.Printed),
sqlF(c.Chassis.WheelbaseMm), sqlF(c.Chassis.TrackMm))
fmt.Fprintf(&b, " %s, %s, %d, %s,\n",
sqlStr(c.Motor.Kind), sqlStr(c.Motor.Class), c.Motor.KV, sqlF(c.Motor.PowerW))
fmt.Fprintf(&b, " %s, %d, %d, %s,\n",
sqlF(c.Battery.VoltageV), c.Battery.CapacityMah, c.Battery.Cells, sqlStr(c.Battery.Chemistry))
fmt.Fprintf(&b, " %s, %s, %s, %s, %s,\n",
sqlStr(c.Drive), sqlF(c.TopSpeedMs), sqlStr(c.ColorHex), sqlStr(c.AvatarURL), sqlBool(c.Active))
fmt.Fprintf(&b, " %d, %d)\n", c.CreatedMs, c.UpdatedMs)
b.WriteString("ON CONFLICT (id) DO NOTHING;\n\n")
}
os.Stdout.WriteString(b.String())
}
func sqlStr(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
func sqlBool(b bool) string {
if b {
return "TRUE"
}
return "FALSE"
}
func sqlF(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}