mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-17 04:57:55 +00:00
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.
297 lines
6.9 KiB
Go
297 lines
6.9 KiB
Go
// Package control owns the authoritative race state.
|
|
//
|
|
// For PoC, the physics model is intentionally simple (no tire slip, no
|
|
// collision): each car integrates position from (throttle, brake, steering).
|
|
// Realistic physics, anti-cheat checks, and CV-based ground truth will be
|
|
// added in MVP / production phases.
|
|
package control
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/x0gp/server/internal/transport"
|
|
)
|
|
|
|
// RacePhase describes the lifecycle of a race.
|
|
type RacePhase int
|
|
|
|
const (
|
|
PhaseLobby RacePhase = 0
|
|
PhaseCountdown RacePhase = 1
|
|
PhaseRacing RacePhase = 2
|
|
PhaseFinished RacePhase = 3
|
|
)
|
|
|
|
func (p RacePhase) String() string {
|
|
switch p {
|
|
case PhaseLobby:
|
|
return "lobby"
|
|
case PhaseCountdown:
|
|
return "countdown"
|
|
case PhaseRacing:
|
|
return "racing"
|
|
case PhaseFinished:
|
|
return "finished"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// Car is the authoritative state of a single car.
|
|
type Car struct {
|
|
ID string
|
|
DriverID string // session id
|
|
DriverName string
|
|
X, Y float64
|
|
Heading float64 // radians
|
|
Speed float64 // m/s
|
|
Lap int
|
|
Sector int
|
|
LastLapMs int64
|
|
BestLapMs int64
|
|
DNF bool
|
|
|
|
// Last applied input (echoed in snapshot for client reconciliation).
|
|
AppliedSteering float64
|
|
AppliedThrottle float64
|
|
AppliedBrake float64
|
|
|
|
mu sync.Mutex // guards input application
|
|
}
|
|
|
|
// ApplyInput applies a control input with ramp limits (sanity-check).
|
|
func (c *Car) ApplyInput(in transport.InputState, dtSec float64) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
// Sanity clamps.
|
|
s := clamp(in.Steering, -1, 1)
|
|
t := clamp(in.Throttle, 0, 1)
|
|
b := clamp(in.Brake, 0, 1)
|
|
|
|
// Throttle / brake -> longitudinal acceleration.
|
|
const maxAccel = 4.0 // m/s^2
|
|
const maxBrake = 8.0 // m/s^2
|
|
const maxSpeed = 6.0 // m/s (1/27 scale, ~21 km/h)
|
|
const dragK = 0.5 // linear drag coefficient
|
|
|
|
accel := maxAccel*t - maxBrake*b - dragK*c.Speed
|
|
c.Speed += accel * dtSec
|
|
if c.Speed < 0 {
|
|
c.Speed = 0
|
|
}
|
|
if c.Speed > maxSpeed {
|
|
c.Speed = maxSpeed
|
|
}
|
|
|
|
// Steering -> yaw rate, proportional to speed (no turn at standstill).
|
|
const maxYawRate = 3.5 // rad/s
|
|
yawRate := maxYawRate * s * (0.3 + 0.7*math.Min(c.Speed/maxSpeed, 1))
|
|
c.Heading += yawRate * dtSec
|
|
|
|
// Position integration.
|
|
c.X += c.Speed * math.Cos(c.Heading) * dtSec
|
|
c.Y += c.Speed * math.Sin(c.Heading) * dtSec
|
|
|
|
c.AppliedSteering = s
|
|
c.AppliedThrottle = t
|
|
c.AppliedBrake = b
|
|
}
|
|
|
|
// Snapshot returns a copy of car state for the wire snapshot.
|
|
func (c *Car) Snapshot() transport.CarInfo {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
info := transport.CarInfo{
|
|
ID: c.ID,
|
|
DriverName: c.DriverName,
|
|
X: c.X,
|
|
Y: c.Y,
|
|
Heading: c.Heading,
|
|
Speed: c.Speed,
|
|
Lap: c.Lap,
|
|
Sector: c.Sector,
|
|
LastLapMs: c.LastLapMs,
|
|
BestLapMs: c.BestLapMs,
|
|
DNF: c.DNF,
|
|
}
|
|
info.InputApplied.Steering = c.AppliedSteering
|
|
info.InputApplied.Throttle = c.AppliedThrottle
|
|
info.InputApplied.Brake = c.AppliedBrake
|
|
return info
|
|
}
|
|
|
|
// Engine is the authoritative race engine.
|
|
//
|
|
// Not safe to copy; pass by pointer.
|
|
type Engine struct {
|
|
mu sync.RWMutex
|
|
phase atomic.Int32 // RacePhase
|
|
tick atomic.Uint64
|
|
cars map[string]*Car // keyed by Car.ID
|
|
byDriver map[string]*Car // keyed by DriverID
|
|
tickRate int
|
|
dtSec float64
|
|
|
|
// Listeners for snapshot fan-out.
|
|
snapCh chan transport.RaceSnapshot
|
|
|
|
// Lifecycle.
|
|
stopCh chan struct{}
|
|
}
|
|
|
|
// NewEngine creates a race engine. tickRateHz is the authoritative tick rate.
|
|
func NewEngine(tickRateHz int) *Engine {
|
|
return &Engine{
|
|
phase: atomic.Int32{},
|
|
cars: make(map[string]*Car),
|
|
byDriver: make(map[string]*Car),
|
|
tickRate: tickRateHz,
|
|
dtSec: 1.0 / float64(tickRateHz),
|
|
snapCh: make(chan transport.RaceSnapshot, 64),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Snapshots returns the channel of authoritative race snapshots.
|
|
func (e *Engine) Snapshots() <-chan transport.RaceSnapshot { return e.snapCh }
|
|
|
|
// Phase returns the current race phase.
|
|
func (e *Engine) Phase() RacePhase { return RacePhase(e.phase.Load()) }
|
|
|
|
// SetPhase transitions the race phase.
|
|
func (e *Engine) SetPhase(p RacePhase) { e.phase.Store(int32(p)) }
|
|
|
|
// AddCar registers a car and returns it. Returns an error if the driver
|
|
// already has a car or the max car count is reached.
|
|
func (e *Engine) AddCar(driverID, driverName string, slot int) (*Car, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
if _, exists := e.byDriver[driverID]; exists {
|
|
return nil, fmt.Errorf("driver %s already has a car", driverID)
|
|
}
|
|
if len(e.cars) >= 4 {
|
|
return nil, fmt.Errorf("race is full (max 4 cars for PoC)")
|
|
}
|
|
|
|
id := fmt.Sprintf("car-%d", slot)
|
|
car := &Car{
|
|
ID: id,
|
|
DriverID: driverID,
|
|
DriverName: driverName,
|
|
// Spread cars along start grid (x = 0, y = slot * 0.4 m).
|
|
Y: float64(slot) * 0.4,
|
|
}
|
|
e.cars[id] = car
|
|
e.byDriver[driverID] = car
|
|
return car, nil
|
|
}
|
|
|
|
// RemoveCar removes a car by driver id.
|
|
func (e *Engine) RemoveCar(driverID string) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
car, ok := e.byDriver[driverID]
|
|
if !ok {
|
|
return
|
|
}
|
|
delete(e.byDriver, driverID)
|
|
delete(e.cars, car.ID)
|
|
}
|
|
|
|
// GetCarByDriver returns the car for a driver (or nil).
|
|
func (e *Engine) GetCarByDriver(driverID string) *Car {
|
|
e.mu.RLock()
|
|
defer e.mu.RUnlock()
|
|
return e.byDriver[driverID]
|
|
}
|
|
|
|
// Run drives the tick loop. Cancelled by ctx or Stop().
|
|
func (e *Engine) Run(ctx context.Context) {
|
|
period := time.Duration(float64(time.Second) / float64(e.tickRate))
|
|
t := time.NewTicker(period)
|
|
defer t.Stop()
|
|
|
|
// Snapshot cadence: 30 Hz for PoC (half tick rate is usually enough).
|
|
const snapshotEveryTicks = 2
|
|
var sinceSnap int
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-e.stopCh:
|
|
return
|
|
case <-t.C:
|
|
e.advance()
|
|
sinceSnap++
|
|
if sinceSnap >= snapshotEveryTicks {
|
|
sinceSnap = 0
|
|
e.publishSnapshot()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop terminates the tick loop.
|
|
func (e *Engine) Stop() { close(e.stopCh) }
|
|
|
|
// advance integrates one tick of physics (no-op if no cars).
|
|
func (e *Engine) advance() {
|
|
e.tick.Add(1)
|
|
e.mu.RLock()
|
|
defer e.mu.RUnlock()
|
|
for _, car := range e.cars {
|
|
if car.DNF {
|
|
continue
|
|
}
|
|
// PoC: keep applying last input without changes.
|
|
// In real life, the input pipeline feeds this.
|
|
in := transport.InputState{
|
|
Steering: car.AppliedSteering,
|
|
Throttle: car.AppliedThrottle,
|
|
Brake: car.AppliedBrake,
|
|
}
|
|
car.ApplyInput(in, e.dtSec)
|
|
}
|
|
}
|
|
|
|
// publishSnapshot sends a snapshot to subscribers (non-blocking).
|
|
func (e *Engine) publishSnapshot() {
|
|
e.mu.RLock()
|
|
cars := make([]transport.CarInfo, 0, len(e.cars))
|
|
for _, c := range e.cars {
|
|
cars = append(cars, c.Snapshot())
|
|
}
|
|
e.mu.RUnlock()
|
|
|
|
snap := transport.RaceSnapshot{
|
|
Tick: e.tick.Load(),
|
|
TSMs: time.Now().UnixMilli(),
|
|
Elapsed: float64(e.tick.Load()) * e.dtSec,
|
|
Cars: cars,
|
|
}
|
|
|
|
select {
|
|
case e.snapCh <- snap:
|
|
default:
|
|
// Drop if subscriber is slow; log in prod.
|
|
}
|
|
}
|
|
|
|
func clamp(v, lo, hi float64) float64 {
|
|
if v < lo {
|
|
return lo
|
|
}
|
|
if v > hi {
|
|
return hi
|
|
}
|
|
return v
|
|
}
|