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,184 @@
|
||||
// Package main contains a small helper that prints the default seeds
|
||||
// (5 F1 tracks + 5 F1 cars) as JSON to stdout. It is run as:
|
||||
//
|
||||
// go run ./scripts/genseed-json > seeds.json
|
||||
//
|
||||
// which is then fed into scripts/genseed to regenerate the SQL.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
"github.com/x0gp/server/internal/catalog/seeddefs"
|
||||
)
|
||||
|
||||
// Mirrors the seeddefs package but in the JSON shape that
|
||||
// scripts/genseed expects.
|
||||
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"`
|
||||
}
|
||||
|
||||
func toWps(in []seeddefs.Waypoint) []waypoint {
|
||||
out := make([]waypoint, len(in))
|
||||
for i, w := range in {
|
||||
out[i] = waypoint{X: w.X, Y: w.Y, HeadingRad: w.HeadingRad, SpeedMs: w.SpeedMs, Curvature: w.Curvature}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toTrack(t seeddefs.Track) track {
|
||||
return track{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
AuthorID: t.AuthorID,
|
||||
Visibility: t.Visibility,
|
||||
Scale: t.Scale,
|
||||
LengthM: t.LengthM,
|
||||
WidthM: t.WidthM,
|
||||
LaneWidthM: t.LaneWidthM,
|
||||
Bounds: bounds{
|
||||
MinX: t.Bounds.MinX, MinY: t.Bounds.MinY,
|
||||
MaxX: t.Bounds.MaxX, MaxY: t.Bounds.MaxY,
|
||||
LengthM: t.Bounds.LengthM, WidthM: t.Bounds.WidthM,
|
||||
},
|
||||
Surface: t.Surface,
|
||||
Tags: t.Tags,
|
||||
Centerline: toWps(t.Centerline),
|
||||
BestLapHolder: t.BestLapHolder,
|
||||
CreatedMs: 1700000000000,
|
||||
UpdatedMs: 1700000000000,
|
||||
}
|
||||
}
|
||||
|
||||
func toCar(c seeddefs.Car) car {
|
||||
return car{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
OwnerID: c.OwnerID,
|
||||
Visibility: c.Visibility,
|
||||
Scale: c.Scale,
|
||||
LengthMm: c.LengthMm,
|
||||
WidthMm: c.WidthMm,
|
||||
HeightMm: c.HeightMm,
|
||||
WeightG: c.WeightG,
|
||||
Chassis: chassis{
|
||||
Model: c.Chassis.Model, Material: c.Chassis.Material,
|
||||
Printed: c.Chassis.Printed, WheelbaseMm: c.Chassis.WheelbaseMm,
|
||||
TrackMm: c.Chassis.TrackMm,
|
||||
},
|
||||
Motor: motor{
|
||||
Kind: c.Motor.Kind, Class: c.Motor.Class,
|
||||
KV: c.Motor.KV, PowerW: c.Motor.PowerW,
|
||||
},
|
||||
Battery: battery{
|
||||
VoltageV: c.Battery.VoltageV, CapacityMah: c.Battery.CapacityMah,
|
||||
Cells: c.Battery.Cells, Chemistry: c.Battery.Chemistry,
|
||||
},
|
||||
Drive: c.Drive,
|
||||
TopSpeedMs: c.TopSpeedMs,
|
||||
ColorHex: c.ColorHex,
|
||||
Active: c.Active,
|
||||
CreatedMs: 1700000000000,
|
||||
UpdatedMs: 1700000000000,
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
srcTracks := seeddefs.DefaultTracks()
|
||||
srcCars := seeddefs.DefaultCars()
|
||||
out := struct {
|
||||
Tracks []track `json:"tracks"`
|
||||
Cars []car `json:"cars"`
|
||||
}{
|
||||
Tracks: make([]track, len(srcTracks)),
|
||||
Cars: make([]car, len(srcCars)),
|
||||
}
|
||||
for i, t := range srcTracks {
|
||||
out.Tracks[i] = toTrack(t)
|
||||
}
|
||||
for i, c := range srcCars {
|
||||
out.Cars[i] = toCar(c)
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(out)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user