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:
x0gp
2026-06-22 22:01:09 +04:00
commit 978d36c505
71 changed files with 23500 additions and 0 deletions
+184
View File
@@ -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)
}