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,109 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConnectDisconnectCounts(t *testing.T) {
|
||||
c := NewCollector()
|
||||
c.Start()
|
||||
c.OnConnect("d1", "Alice")
|
||||
c.OnConnect("d2", "Bob")
|
||||
c.OnDisconnect("d1", 5000)
|
||||
|
||||
s := c.Server()
|
||||
if s.CurrentClients != 1 {
|
||||
t.Errorf("CurrentClients: got %d", s.CurrentClients)
|
||||
}
|
||||
if s.PeakClients != 2 {
|
||||
t.Errorf("PeakClients: got %d", s.PeakClients)
|
||||
}
|
||||
if s.TotalConnections != 2 {
|
||||
t.Errorf("TotalConnections: got %d", s.TotalConnections)
|
||||
}
|
||||
if s.TotalDisconnects != 1 {
|
||||
t.Errorf("TotalDisconnects: got %d", s.TotalDisconnects)
|
||||
}
|
||||
if s.UptimeMs < 0 {
|
||||
t.Errorf("UptimeMs negative: %d", s.UptimeMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLapAndDriverProfile(t *testing.T) {
|
||||
c := NewCollector()
|
||||
c.Start()
|
||||
c.OnConnect("d1", "Alice")
|
||||
c.OnLapRecorded(LapRecord{RaceID: "r1", DriverID: "d1", LapMs: 12_345, TSMs: time.Now().UnixMilli()})
|
||||
c.OnLapRecorded(LapRecord{RaceID: "r1", DriverID: "d1", LapMs: 11_500, TSMs: time.Now().UnixMilli()})
|
||||
c.OnLapRecorded(LapRecord{RaceID: "r1", DriverID: "d1", LapMs: 13_000, TSMs: time.Now().UnixMilli()})
|
||||
|
||||
d := c.Driver("d1", "Alice")
|
||||
if d.TotalLaps != 3 {
|
||||
t.Errorf("TotalLaps: got %d", d.TotalLaps)
|
||||
}
|
||||
if d.BestLapMs != 11_500 {
|
||||
t.Errorf("BestLapMs: got %d", d.BestLapMs)
|
||||
}
|
||||
if d.LastLapMs != 13_000 {
|
||||
t.Errorf("LastLapMs: got %d", d.LastLapMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaceFinishedUpdatesProfile(t *testing.T) {
|
||||
c := NewCollector()
|
||||
c.Start()
|
||||
c.OnConnect("d1", "Alice")
|
||||
c.OnConnect("d2", "Bob")
|
||||
c.OnRaceStarted([]string{"d1", "d2"})
|
||||
|
||||
c.OnRaceFinished(RaceResult{RaceID: "r1", DriverID: "d1", Position: 1, TotalLaps: 5, BestLapMs: 11_000, TotalTimeMs: 60_000})
|
||||
c.OnRaceFinished(RaceResult{RaceID: "r1", DriverID: "d2", Position: 2, TotalLaps: 5, BestLapMs: 11_500, TotalTimeMs: 60_500})
|
||||
|
||||
d1 := c.Driver("d1", "")
|
||||
if d1.Wins != 1 {
|
||||
t.Errorf("Wins: got %d", d1.Wins)
|
||||
}
|
||||
if d1.BestLapMs != 11_000 {
|
||||
t.Errorf("BestLapMs: got %d", d1.BestLapMs)
|
||||
}
|
||||
d2 := c.Driver("d2", "")
|
||||
if d2.Wins != 0 || d2.Podiums != 1 {
|
||||
t.Errorf("Podiums: got %d", d2.Podiums)
|
||||
}
|
||||
|
||||
s := c.Server()
|
||||
if s.TotalRacesStarted != 1 || s.TotalRacesFinished != 2 {
|
||||
t.Errorf("counters: started=%d finished=%d", s.TotalRacesStarted, s.TotalRacesFinished)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRTTWindowAverage(t *testing.T) {
|
||||
c := NewCollector()
|
||||
c.Start()
|
||||
for _, v := range []int64{10, 20, 30, 40, 50} {
|
||||
c.OnRTT(v)
|
||||
}
|
||||
s := c.Server()
|
||||
if s.AvgRTTMs != 30 {
|
||||
t.Errorf("AvgRTTMs: got %v want 30", s.AvgRTTMs)
|
||||
}
|
||||
if len(s.RecentRTTs) != 5 {
|
||||
t.Errorf("RecentRTTs len: got %d", len(s.RecentRTTs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentLapsOrder(t *testing.T) {
|
||||
c := NewCollector()
|
||||
c.Start()
|
||||
for i := 0; i < 5; i++ {
|
||||
c.OnLapRecorded(LapRecord{RaceID: "r1", DriverID: "d1", LapMs: int64(10_000 + i), TSMs: int64(i)})
|
||||
}
|
||||
laps := c.RecentLaps(3)
|
||||
if len(laps) != 3 {
|
||||
t.Fatalf("got %d laps", len(laps))
|
||||
}
|
||||
if laps[0].TSMs < laps[1].TSMs {
|
||||
t.Errorf("expected newest first, got order: %v", laps)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// Package stats collects and exposes server and per-driver metrics.
|
||||
//
|
||||
// PoC: in-memory only, atomic counters where possible, mutex around
|
||||
// driver and race records. The data lives for the lifetime of the
|
||||
// process. A future storage layer will swap this for Postgres/Redis.
|
||||
//
|
||||
// Public surface is split into:
|
||||
// - lifecycle hooks (Connect/Disconnect/Race*/Lap*/Input*/RTT) —
|
||||
// called from cmd/poc-server and from internal/control and lobby
|
||||
// - Snapshot() — full server snapshot for /stats/detailed
|
||||
// - DriverProfile(id) — per-driver summary
|
||||
// - RecentLaps(n) — last N laps across all races
|
||||
package stats
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ServerStats is the top-level metrics snapshot.
|
||||
type ServerStats struct {
|
||||
StartedMs int64 `json:"started_ms"`
|
||||
UptimeMs int64 `json:"uptime_ms"`
|
||||
CurrentClients int64 `json:"current_clients"`
|
||||
PeakClients int64 `json:"peak_clients"`
|
||||
TotalConnections uint64 `json:"total_connections"`
|
||||
TotalDisconnects uint64 `json:"total_disconnects"`
|
||||
TotalRacesCreated uint64 `json:"total_races_created"`
|
||||
TotalRacesStarted uint64 `json:"total_races_started"`
|
||||
TotalRacesFinished uint64 `json:"total_races_finished"`
|
||||
TotalLapsRecorded uint64 `json:"total_laps_recorded"`
|
||||
TotalInputsRecv uint64 `json:"total_inputs_received"`
|
||||
TotalSnapshotsSent uint64 `json:"total_snapshots_sent"`
|
||||
SnapshotsDropped uint64 `json:"snapshots_dropped"`
|
||||
AvgRTTMs float64 `json:"avg_rtt_ms"`
|
||||
RecentRTTs []int64 `json:"recent_rtt_ms"` // windowed samples
|
||||
}
|
||||
|
||||
// DriverProfile aggregates per-driver statistics.
|
||||
type DriverProfile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TotalRacesStarted uint64 `json:"total_races_started"`
|
||||
TotalRacesFinished uint64 `json:"total_races_finished"`
|
||||
TotalLaps uint64 `json:"total_laps"`
|
||||
BestLapMs int64 `json:"best_lap_ms"`
|
||||
LastLapMs int64 `json:"last_lap_ms"`
|
||||
TotalDistanceM float64 `json:"total_distance_m"`
|
||||
TotalPlaytimeMs int64 `json:"total_playtime_ms"`
|
||||
Wins uint64 `json:"wins"`
|
||||
Podiums uint64 `json:"podiums"`
|
||||
LastSeenMs int64 `json:"last_seen_ms"`
|
||||
}
|
||||
|
||||
// LapRecord is one lap, kept for /stats/recent.
|
||||
type LapRecord struct {
|
||||
RaceID string `json:"race_id"`
|
||||
DriverID string `json:"driver_id"`
|
||||
LapMs int64 `json:"lap_ms"`
|
||||
Sector1Ms int64 `json:"sector1_ms"`
|
||||
Sector2Ms int64 `json:"sector2_ms"`
|
||||
Sector3Ms int64 `json:"sector3_ms"`
|
||||
Position int `json:"position,omitempty"`
|
||||
TSMs int64 `json:"ts_ms"`
|
||||
}
|
||||
|
||||
// RaceResult is the final classification of a race for one driver.
|
||||
type RaceResult struct {
|
||||
RaceID string `json:"race_id"`
|
||||
DriverID string `json:"driver_id"`
|
||||
FinishedAtMs int64 `json:"finished_at_ms"`
|
||||
Position int `json:"position"`
|
||||
TotalLaps int `json:"total_laps"`
|
||||
BestLapMs int64 `json:"best_lap_ms"`
|
||||
TotalTimeMs int64 `json:"total_time_ms"`
|
||||
DNF bool `json:"dnf"`
|
||||
}
|
||||
|
||||
// collector owns the runtime state.
|
||||
type collector struct {
|
||||
startedMs atomic.Int64
|
||||
|
||||
currentClients atomic.Int64
|
||||
peakClients atomic.Int64
|
||||
|
||||
totalConn atomic.Uint64
|
||||
totalDisc atomic.Uint64
|
||||
totalRacesC atomic.Uint64
|
||||
totalRacesS atomic.Uint64
|
||||
totalRacesF atomic.Uint64
|
||||
totalLaps atomic.Uint64
|
||||
totalInputs atomic.Uint64
|
||||
totalSnapsSent atomic.Uint64
|
||||
totalSnapDrops atomic.Uint64
|
||||
|
||||
// RTT moving window (last 32 samples).
|
||||
rttMu sync.Mutex
|
||||
rttWin []int64
|
||||
}
|
||||
|
||||
// Collector is the public stats service.
|
||||
type Collector struct {
|
||||
c collector
|
||||
|
||||
mu sync.RWMutex
|
||||
drivers map[string]*DriverProfile
|
||||
laps []LapRecord // ring buffer
|
||||
lapsHead int
|
||||
lapsCap int
|
||||
|
||||
results []RaceResult
|
||||
resultsMax int
|
||||
}
|
||||
|
||||
// NewCollector returns a fresh collector.
|
||||
func NewCollector() *Collector {
|
||||
return &Collector{
|
||||
c: collector{},
|
||||
drivers: make(map[string]*DriverProfile),
|
||||
lapsCap: 200,
|
||||
laps: make([]LapRecord, 200),
|
||||
resultsMax: 500,
|
||||
results: make([]RaceResult, 0, 500),
|
||||
}
|
||||
}
|
||||
|
||||
// Start marks process start time. Call once.
|
||||
func (c *Collector) Start() {
|
||||
c.c.startedMs.Store(time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
// Lifecycle hooks --------------------------------------------------------
|
||||
|
||||
// OnConnect increments counters and starts a session.
|
||||
func (c *Collector) OnConnect(clientID, name string) {
|
||||
c.c.totalConn.Add(1)
|
||||
cur := c.c.currentClients.Add(1)
|
||||
for {
|
||||
peak := c.c.peakClients.Load()
|
||||
if cur <= peak || c.c.peakClients.CompareAndSwap(peak, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.touchDriver(clientID, name)
|
||||
}
|
||||
|
||||
// OnDisconnect decrements current and adds session duration.
|
||||
func (c *Collector) OnDisconnect(clientID string, sessionMs int64) {
|
||||
c.c.currentClients.Add(-1)
|
||||
c.c.totalDisc.Add(1)
|
||||
c.mu.Lock()
|
||||
if d, ok := c.drivers[clientID]; ok {
|
||||
d.TotalPlaytimeMs += sessionMs
|
||||
d.LastSeenMs = time.Now().UnixMilli()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// OnRaceCreated increments a counter.
|
||||
func (c *Collector) OnRaceCreated() { c.c.totalRacesC.Add(1) }
|
||||
|
||||
// OnRaceStarted increments a counter and bumps driver race count.
|
||||
func (c *Collector) OnRaceStarted(driverIDs []string) {
|
||||
c.c.totalRacesS.Add(1)
|
||||
c.mu.Lock()
|
||||
for _, id := range driverIDs {
|
||||
if d, ok := c.drivers[id]; ok {
|
||||
d.TotalRacesStarted++
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// OnRaceFinished records final classification. Position is 1-based.
|
||||
func (c *Collector) OnRaceFinished(result RaceResult) {
|
||||
c.c.totalRacesF.Add(1)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if d, ok := c.drivers[result.DriverID]; ok {
|
||||
if !result.DNF {
|
||||
d.TotalRacesFinished++
|
||||
}
|
||||
if result.Position == 1 {
|
||||
d.Wins++
|
||||
} else if result.Position >= 1 && result.Position <= 3 {
|
||||
d.Podiums++
|
||||
}
|
||||
if result.BestLapMs > 0 && (d.BestLapMs == 0 || result.BestLapMs < d.BestLapMs) {
|
||||
d.BestLapMs = result.BestLapMs
|
||||
}
|
||||
}
|
||||
c.results = append(c.results, result)
|
||||
if len(c.results) > c.resultsMax {
|
||||
// drop oldest 10%
|
||||
drop := c.resultsMax / 10
|
||||
c.results = c.results[drop:]
|
||||
}
|
||||
}
|
||||
|
||||
// OnLapRecorded stores lap in ring buffer and updates driver totals.
|
||||
func (c *Collector) OnLapRecorded(rec LapRecord) {
|
||||
c.c.totalLaps.Add(1)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if d, ok := c.drivers[rec.DriverID]; ok {
|
||||
d.TotalLaps++
|
||||
d.LastLapMs = rec.LapMs
|
||||
if rec.LapMs > 0 && (d.BestLapMs == 0 || rec.LapMs < d.BestLapMs) {
|
||||
d.BestLapMs = rec.LapMs
|
||||
}
|
||||
}
|
||||
c.laps[c.lapsHead] = rec
|
||||
c.lapsHead = (c.lapsHead + 1) % c.lapsCap
|
||||
}
|
||||
|
||||
// OnInputProcessed increments input counter.
|
||||
func (c *Collector) OnInputProcessed() { c.c.totalInputs.Add(1) }
|
||||
|
||||
// OnSnapshotSent increments snapshot counter and the drop counter.
|
||||
func (c *Collector) OnSnapshotSent(sent, dropped uint64) {
|
||||
c.c.totalSnapsSent.Add(sent)
|
||||
c.c.totalSnapDrops.Add(dropped)
|
||||
}
|
||||
|
||||
// OnRTT adds a sample to the moving window.
|
||||
func (c *Collector) OnRTT(rttMs int64) {
|
||||
if rttMs < 0 {
|
||||
return
|
||||
}
|
||||
c.c.rttMu.Lock()
|
||||
defer c.c.rttMu.Unlock()
|
||||
c.c.rttWin = append(c.c.rttWin, rttMs)
|
||||
if len(c.c.rttWin) > 32 {
|
||||
c.c.rttWin = c.c.rttWin[len(c.c.rttWin)-32:]
|
||||
}
|
||||
}
|
||||
|
||||
// OnDistance accumulates meters driven (called per snapshot).
|
||||
func (c *Collector) OnDistance(driverID string, meters float64) {
|
||||
if meters <= 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
if d, ok := c.drivers[driverID]; ok {
|
||||
d.TotalDistanceM += meters
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Snapshots ---------------------------------------------------------------
|
||||
|
||||
// Server returns the current server-level metrics.
|
||||
func (c *Collector) Server() ServerStats {
|
||||
now := time.Now().UnixMilli()
|
||||
c.c.rttMu.Lock()
|
||||
window := append([]int64(nil), c.c.rttWin...)
|
||||
c.c.rttMu.Unlock()
|
||||
|
||||
var avg float64
|
||||
if len(window) > 0 {
|
||||
var sum int64
|
||||
for _, v := range window {
|
||||
sum += v
|
||||
}
|
||||
avg = float64(sum) / float64(len(window))
|
||||
}
|
||||
|
||||
return ServerStats{
|
||||
StartedMs: c.c.startedMs.Load(),
|
||||
UptimeMs: now - c.c.startedMs.Load(),
|
||||
CurrentClients: c.c.currentClients.Load(),
|
||||
PeakClients: c.c.peakClients.Load(),
|
||||
TotalConnections: c.c.totalConn.Load(),
|
||||
TotalDisconnects: c.c.totalDisc.Load(),
|
||||
TotalRacesCreated: c.c.totalRacesC.Load(),
|
||||
TotalRacesStarted: c.c.totalRacesS.Load(),
|
||||
TotalRacesFinished: c.c.totalRacesF.Load(),
|
||||
TotalLapsRecorded: c.c.totalLaps.Load(),
|
||||
TotalInputsRecv: c.c.totalInputs.Load(),
|
||||
TotalSnapshotsSent: c.c.totalSnapsSent.Load(),
|
||||
SnapshotsDropped: c.c.totalSnapDrops.Load(),
|
||||
AvgRTTMs: avg,
|
||||
RecentRTTs: window,
|
||||
}
|
||||
}
|
||||
|
||||
// Driver returns the profile for one driver, creating it if missing.
|
||||
func (c *Collector) Driver(id, name string) DriverProfile {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
d, ok := c.drivers[id]
|
||||
if !ok {
|
||||
d = &DriverProfile{ID: id, Name: name}
|
||||
c.drivers[id] = d
|
||||
} else if name != "" {
|
||||
d.Name = name
|
||||
}
|
||||
return *d
|
||||
}
|
||||
|
||||
// Drivers returns a copy of all driver profiles.
|
||||
func (c *Collector) Drivers() []DriverProfile {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := make([]DriverProfile, 0, len(c.drivers))
|
||||
for _, d := range c.drivers {
|
||||
out = append(out, *d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RecentLaps returns the last N lap records (newest first).
|
||||
func (c *Collector) RecentLaps(n int) []LapRecord {
|
||||
if n <= 0 || n > c.lapsCap {
|
||||
n = c.lapsCap
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := make([]LapRecord, 0, n)
|
||||
// Walk backwards from lapsHead.
|
||||
for i := 0; i < c.lapsCap && len(out) < n; i++ {
|
||||
idx := (c.lapsHead - 1 - i + c.lapsCap) % c.lapsCap
|
||||
if c.laps[idx].TSMs == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, c.laps[idx])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RecentResults returns the last N race results.
|
||||
func (c *Collector) RecentResults(n int) []RaceResult {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if n <= 0 || n > len(c.results) {
|
||||
n = len(c.results)
|
||||
}
|
||||
out := make([]RaceResult, n)
|
||||
copy(out, c.results[len(c.results)-n:])
|
||||
// reverse for newest-first
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// helpers ----------------------------------------------------------------
|
||||
|
||||
// touchDriver creates a profile if missing and updates LastSeenMs.
|
||||
func (c *Collector) touchDriver(id, name string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
d, ok := c.drivers[id]
|
||||
if !ok {
|
||||
c.drivers[id] = &DriverProfile{ID: id, Name: name, LastSeenMs: time.Now().UnixMilli()}
|
||||
return
|
||||
}
|
||||
d.LastSeenMs = time.Now().UnixMilli()
|
||||
if name != "" && d.Name == "" {
|
||||
d.Name = name
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user