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
+562
View File
@@ -0,0 +1,562 @@
package seed
import (
"context"
"fmt"
"math/rand"
"time"
"github.com/x0gp/server/internal/clans"
"github.com/x0gp/server/internal/drivers"
"github.com/x0gp/server/internal/lobby"
"github.com/x0gp/server/internal/races"
)
// DefaultCounts are the volumes produced by Run.
type DefaultCounts struct {
Finished int // default 30
Live int // default 5
Plans int // default 5
}
// DefaultSeed is the deterministic seed.
const DefaultSeed = 0xA1B2C3D4
// DefaultNow is overridable for tests.
var DefaultNow = time.Now
// Track catalogue used by the seeder. Loaded from the tracks table at
// run time. If no tracks exist, the seeder falls back to ["default"].
var fallbackTracks = []string{"default"}
// Default driver nicknames used when the drivers table is empty. Three
// uppercase letters each (per the drivers table constraint).
var defaultDriverSeeds = []struct {
nick string
name string
}{
{"ACE", "Alice"},
{"BOB", "Bob"},
{"CAR", "Carol"},
{"DAV", "Dave"},
{"EVE", "Eve"},
{"FRA", "Frank"},
{"GRA", "Grace"},
{"HEI", "Heidi"},
}
// Default clans.
var defaultClanSeeds = []struct {
tag string
name string
}{
{"ACE", "Ace Racing"},
{"RND", "Rounders"},
{"SPD", "Speed Demons"},
}
// Options configures Run.
type Options struct {
Counts DefaultCounts
Seed int64
Reset bool
Now time.Time
}
// Summary is the result of Run.
type Summary struct {
FinishedInserted int
LiveCreated int
PlansInserted int
QueueInserted int
Duration time.Duration
}
// Runner is a self-contained seeder wired to the package's pg store and
// lobby. Hold one per process; safe to call Run multiple times.
type Runner struct {
pg *races.PgStore
lb *lobby.Service
live *races.LiveStore
clansPg *clans.PgStore
driversPg *drivers.PgStore
opt Options
rng *rand.Rand
now time.Time
// Filled by loadInputs.
trackIDs []string
driverIDs []string // driver ids in the order they exist after seed
driverInfo []seedDriver // id, nick, name, clanID, clanTag
clanByID map[string]string // clan id -> tag (for fast profile updates)
}
type seedDriver struct {
ID string
Nickname string
Name string
ClanID string
ClanTag string
}
// NewRunner constructs a Runner.
func NewRunner(pg *races.PgStore, lb *lobby.Service, live *races.LiveStore, clansPg *clans.PgStore, driversPg *drivers.PgStore) *Runner {
return &Runner{pg: pg, lb: lb, live: live, clansPg: clansPg, driversPg: driversPg}
}
// Run executes the seed against the supplied services.
//
// Order of operations:
//
// 1. (optional) reset: TRUNCATE finished_races, race_plans, race_queue;
// remove all live races from the lobby; remove the auto-seeded drivers
// from the lobby.
// 2. Insert N finished races into Postgres. These do NOT touch the lobby.
// 3. Create M live races in the lobby (mix of statuses).
// 4. Insert K race plans into Postgres. The scheduler will materialise
// them on its next tick (start_at_ms is in the future).
// 5. Enqueue the first two drivers for the next 2 live races.
func (r *Runner) Run(ctx context.Context, opt Options) (Summary, error) {
if opt.Seed == 0 {
opt.Seed = DefaultSeed
}
if opt.Now.IsZero() {
opt.Now = DefaultNow()
}
if opt.Counts.Finished == 0 {
opt.Counts.Finished = 30
}
if opt.Counts.Live == 0 {
opt.Counts.Live = 5
}
if opt.Counts.Plans == 0 {
opt.Counts.Plans = 5
}
start := time.Now()
var s Summary
if opt.Reset {
if err := r.resetAll(ctx); err != nil {
return s, fmt.Errorf("reset: %w", err)
}
}
r.rng = rand.New(rand.NewSource(opt.Seed))
r.now = opt.Now
// 0. Seed drivers and clans (idempotent). Then load real track ids
// from the catalog so race_plans / finished_races only reference
// tracks that actually exist in the DB.
if err := r.seedDriversAndClans(ctx); err != nil {
return s, fmt.Errorf("seed drivers/clans: %w", err)
}
if err := r.loadTracks(ctx); err != nil {
return s, fmt.Errorf("load tracks: %w", err)
}
if err := r.loadDrivers(ctx); err != nil {
return s, fmt.Errorf("load drivers: %w", err)
}
// 1. Finished races.
for i := 0; i < opt.Counts.Finished; i++ {
row := r.makeFinished(i)
if err := r.pg.InsertFinished(ctx, row); err != nil {
return s, fmt.Errorf("insert finished %d: %w", i, err)
}
s.FinishedInserted++
}
// 2. Live races in the lobby.
statuses := []lobby.RaceStatus{
lobby.RaceStatusLobby,
lobby.RaceStatusLobby,
lobby.RaceStatusCountdown,
lobby.RaceStatusRacing,
lobby.RaceStatusLobby,
}
for i := 0; i < opt.Counts.Live && i < len(statuses); i++ {
meta, err := r.createLive(statuses[i], i)
if err != nil {
return s, fmt.Errorf("create live %d: %w", i, err)
}
if statuses[i] != lobby.RaceStatusLobby {
if err := r.lb.SetRaceStatus(meta.ID, statuses[i]); err != nil {
return s, fmt.Errorf("set status %s: %w", meta.ID, err)
}
// Flush the new status synchronously so the DB row is in
// sync with in-memory (mirror goroutines may not have run
// yet).
if r.live != nil {
flushCtx, flushCancel := context.WithTimeout(ctx, 2*time.Second)
cur, err := r.lb.GetRace(meta.ID)
if err == nil {
_ = r.live.SetRaceStatus(flushCtx, cur.ID, cur.Status, cur.StartedMs)
}
flushCancel()
}
}
s.LiveCreated++
// First two live races: enqueue first two drivers on this race.
if i < 2 {
for _, d := range r.driverInfo[:2] {
if _, err := r.pg.Enqueue(ctx, d.ID, meta.ID, ""); err != nil {
return s, fmt.Errorf("enqueue %s: %w", d.ID, err)
}
s.QueueInserted++
}
}
}
// 3. Race plans. Mix of one-shot and recurring.
planShapes := []planShape{
{intervalS: 0, offsetS: 5 * 60}, // one-shot in 5 min
{intervalS: 60, offsetS: 30 * 60}, // every minute, start in 30 min
{intervalS: 3600, count: 24, offsetS: 60 * 60}, // hourly x24, start in 1h
{intervalS: 0, offsetS: 6 * 3600}, // one-shot in 6h
{intervalS: 86400, offsetS: 24 * 3600}, // daily, start tomorrow
}
for i := 0; i < opt.Counts.Plans && i < len(planShapes); i++ {
ps := planShapes[i]
p := r.makePlan(i, ps)
if err := r.pg.CreatePlan(ctx, p); err != nil {
return s, fmt.Errorf("create plan %d: %w", i, err)
}
s.PlansInserted++
}
s.Duration = time.Since(start)
return s, nil
}
type planShape struct {
intervalS int
count int
offsetS int
}
// resetAll wipes the seed-managed rows in the unified races table
// (only finished | cancelled; live rows are removed via lobby
// cascade), plus the plan and queue tables. Drivers and clans are
// kept (they're independent of races).
func (r *Runner) resetAll(ctx context.Context) error {
if _, err := r.pg.Exec(ctx,
`DELETE FROM races WHERE status IN ('finished','cancelled')`); err != nil {
return err
}
if _, err := r.pg.Exec(ctx,
`TRUNCATE TABLE race_plans, race_queue`); err != nil {
return err
}
for _, m := range r.lb.ListRaces() {
_ = r.lb.DeleteRace(m.ID)
}
if _, err := r.pg.Exec(ctx, `TRUNCATE TABLE lobby_drivers`); err != nil {
return err
}
return nil
}
// seedDriversAndClans inserts the default driver and clan set if the
// tables are empty. Idempotent: existing rows are left alone.
func (r *Runner) seedDriversAndClans(ctx context.Context) error {
clanCount, err := r.clansPg.Count(ctx)
if err != nil {
return fmt.Errorf("count clans: %w", err)
}
if clanCount == 0 {
now := time.Now().UnixMilli()
for i, cs := range defaultClanSeeds {
c := clans.Clan{
ID: fmt.Sprintf("clan-seed-%03d", i+1),
Tag: cs.tag,
Name: cs.name,
AvatarURL: fmt.Sprintf("https://cdn.example.com/clans/%s.png", cs.tag),
CreatedMs: now,
UpdatedMs: now,
}
if err := r.clansPg.Create(ctx, c); err != nil {
return fmt.Errorf("insert clan %s: %w", cs.tag, err)
}
}
}
drvCount, err := r.driversPg.Count(ctx)
if err != nil {
return fmt.Errorf("count drivers: %w", err)
}
if drvCount == 0 {
now := time.Now().UnixMilli()
// Round-robin: each driver assigned to one of the seeded clans.
clanIDs := make([]string, 0, len(defaultClanSeeds))
for i := range defaultClanSeeds {
clanIDs = append(clanIDs, fmt.Sprintf("clan-seed-%03d", i+1))
}
for i, ds := range defaultDriverSeeds {
d := drivers.Driver{
ID: fmt.Sprintf("driver-seed-%03d", i+1),
Nickname: ds.nick,
Name: ds.name,
AvatarURL: fmt.Sprintf("https://cdn.example.com/drivers/%s.png", ds.nick),
ClanID: clanIDs[i%len(clanIDs)],
CreatedMs: now,
UpdatedMs: now,
}
if err := r.driversPg.Create(ctx, d); err != nil {
return fmt.Errorf("insert driver %s: %w", ds.nick, err)
}
}
}
return nil
}
// loadTracks fills r.trackIDs from the tracks table. Falls back to
// fallbackTracks if the table is empty.
func (r *Runner) loadTracks(ctx context.Context) error {
ids, err := r.pg.ListTrackIDs(ctx)
if err != nil {
return err
}
if len(ids) == 0 {
ids = fallbackTracks
}
r.trackIDs = ids
return nil
}
// loadDrivers fills r.driverIDs and r.driverInfo from the drivers table
// in the DB, then propagates the profile (nickname/avatar/clan) to the
// in-memory lobby drivers.
func (r *Runner) loadDrivers(ctx context.Context) error {
// Pull all drivers, ordered by nickname.
clanByID := make(map[string]string)
clansList, err := r.clansPg.List(ctx, 200, 0)
if err != nil {
return fmt.Errorf("list clans: %w", err)
}
for _, c := range clansList {
clanByID[c.ID] = c.Tag
}
r.clanByID = clanByID
drvList, err := r.driversPg.List(ctx, 200, 0, "")
if err != nil {
return fmt.Errorf("list drivers: %w", err)
}
r.driverIDs = r.driverIDs[:0]
r.driverInfo = r.driverInfo[:0]
for _, d := range drvList {
r.driverIDs = append(r.driverIDs, d.ID)
r.driverInfo = append(r.driverInfo, seedDriver{
ID: d.ID,
Nickname: d.Nickname,
Name: d.Name,
ClanID: d.ClanID,
ClanTag: clanByID[d.ClanID],
})
}
return nil
}
// ---------------------------------------------------------------------------
// Generators
// ---------------------------------------------------------------------------
func (r *Runner) makeFinished(idx int) races.FinishedRace {
rng := r.rng
now := r.now
// Spread across the last 30 days.
back := time.Duration(30*24-rng.Intn(30*24-1)) * time.Hour
finishedMs := now.Add(-back).UnixMilli()
startedMs := finishedMs - randDuration(rng, 90, 600).Milliseconds()
track := r.trackIDs[rng.Intn(len(r.trackIDs))]
laps := trackLaps(track)
// Pick 2..4 random drivers from the seeded set.
shuffled := append([]seedDriver(nil), r.driverInfo...)
rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })
n := 2 + rng.Intn(3) // 2..4 drivers
if n > len(shuffled) {
n = len(shuffled)
}
picked := shuffled[:n]
driverIDs := make([]string, n)
for i, d := range picked {
driverIDs[i] = d.ID
}
winner := picked[rng.Intn(len(picked))]
totalLaps := laps
if rng.Intn(5) == 0 {
// ~20% DNF/partial.
totalLaps = rng.Intn(laps)
}
bestLapMs := int64(20_000 + rng.Intn(8_000))
// Podium: top-3 by total time. The winner always finishes; the
// remaining drivers are sorted by a per-driver total time derived
// from the winner's best lap.
perDriver := make([]int64, len(picked))
for i, d := range picked {
if d.ID == winner.ID {
perDriver[i] = int64(totalLaps) * bestLapMs
} else {
perDriver[i] = int64(totalLaps)*bestLapMs + int64(2+rng.Intn(15))*1000
}
}
type dp struct {
d seedDriver
time int64
}
all := make([]dp, len(picked))
for i := range picked {
all[i] = dp{picked[i], perDriver[i]}
}
// Insertion sort by time ascending.
for i := 1; i < len(all); i++ {
for j := i; j > 0 && all[j-1].time > all[j].time; j-- {
all[j-1], all[j] = all[j], all[j-1]
}
}
podium := make([]races.PodiumEntry, 0, 3)
for i := 0; i < len(all) && i < 3; i++ {
podium = append(podium, races.PodiumEntry{
Position: i + 1,
DriverID: all[i].d.ID,
Name: all[i].d.Nickname,
TotalTimeMs: all[i].time,
})
}
return races.FinishedRace{
ID: fmt.Sprintf("seed-finished-%03d", idx+1),
Name: fmt.Sprintf("Seed Race #%d (%s)", idx+1, track),
TrackID: track,
MaxCars: 4,
Laps: laps,
TimeLimitS: 0,
DriverIDs: driverIDs,
Status: "finished",
CreatedMs: finishedMs - randDuration(rng, 5, 120).Milliseconds(),
StartedMs: startedMs,
FinishedMs: finishedMs,
DurationMs: finishedMs - startedMs,
TotalLaps: totalLaps,
TotalDrivers: len(driverIDs),
WinnerDriverID: winner.ID,
WinnerName: winner.Nickname,
BestLapMs: bestLapMs,
Podium: podium,
}
}
func (r *Runner) createLive(status lobby.RaceStatus, idx int) (lobby.RaceMeta, error) {
rng := r.rng
track := r.trackIDs[rng.Intn(len(r.trackIDs))]
meta, err := r.lb.CreateRace(lobby.CreateRaceOptions{
Name: fmt.Sprintf("Live %s #%d", track, idx+1),
TrackID: track,
MaxCars: 4,
Laps: trackLaps(track),
TimeLimitS: 0,
})
if err != nil {
return lobby.RaceMeta{}, err
}
// Add 1..3 random drivers into the race. We re-add them to refresh
// any prior lobby state, then attach the profile (nickname/avatar/
// clan) so the WS broadcast carries rich metadata.
shuffled := append([]seedDriver(nil), r.driverInfo...)
rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })
n := 1 + rng.Intn(3)
if n > len(shuffled) {
n = len(shuffled)
}
for i := 0; i < n; i++ {
d := shuffled[i]
r.lb.RemoveDriver(d.ID)
if _, err := r.lb.AddDriver(d.ID, d.Name, ""); err != nil {
continue
}
r.lb.SetDriverProfile(d.ID, d.Nickname, "", d.ClanID, d.ClanTag)
if err := r.lb.AddDriverToRace(d.ID, meta.ID); err != nil {
// ignore: race may be at capacity
}
}
// Synchronously flush the race to the DB so the persistence mirror
// (which uses detached goroutines) doesn't race with subsequent
// status changes. The mirror calls in lobby.Service are still
// fired; this upsert is a guarantee, not a replacement.
if r.live != nil {
flushCtx, flushCancel := context.WithTimeout(context.Background(), 2*time.Second)
defer flushCancel()
// Re-fetch the race from the in-memory lobby to get the latest
// driver list and started_ms.
cur, err := r.lb.GetRace(meta.ID)
if err == nil {
_ = r.live.UpsertRace(flushCtx, cur)
for slot, did := range cur.DriverIDs {
_ = r.live.AddDriver(flushCtx, cur.ID, did, slot)
}
}
}
_ = status
return meta, nil
}
func (r *Runner) makePlan(idx int, ps planShape) races.RacePlan {
rng := r.rng
now := r.now
track := r.trackIDs[rng.Intn(len(r.trackIDs))]
startAt := now.Add(time.Duration(ps.offsetS) * time.Second).UnixMilli()
return races.RacePlan{
ID: fmt.Sprintf("seed-plan-%03d", idx+1),
Name: fmt.Sprintf("Seed Plan #%d (%s)", idx+1, track),
TrackID: track,
MaxCars: 4,
Laps: trackLaps(track),
TimeLimitS: 0,
StartAtMs: startAt,
IntervalS: ps.intervalS,
Count: ps.count,
Enabled: true,
NextFireMs: startAt,
}
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
func trackLaps(track string) int {
switch track {
case "nordschleife":
return 3
case "monza":
return 12
case "spa":
return 10
case "silverstone":
return 8
case "suzuka":
return 9
default:
return 5
}
}
func randDuration(rng *rand.Rand, loSec, hiSec int) time.Duration {
return time.Duration(loSec+rng.Intn(hiSec-loSec+1)) * time.Second
}
func removeFrom(in []string, v string) []string {
out := make([]string, 0, len(in)-1)
for _, x := range in {
if x != v {
out = append(out, x)
}
}
return out
}