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,836 @@
|
||||
// Package transport defines the wire format between clients and the server.
|
||||
//
|
||||
// PoC: JSON for fast iteration. Specified by proto/client_server.proto for
|
||||
// the production binary protobuf format.
|
||||
//
|
||||
// TODO PoC: replace JSON with protobuf-generated structs (see
|
||||
// ../proto/client_server.proto).
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MessageType identifies the kind of payload.
|
||||
type MessageType string
|
||||
|
||||
const (
|
||||
// Client -> Server.
|
||||
TypeClientHello MessageType = "client_hello"
|
||||
TypeJoinRace MessageType = "join_race"
|
||||
TypeLeaveRace MessageType = "leave_race"
|
||||
TypeInputState MessageType = "input_state"
|
||||
TypeChatMessage MessageType = "chat_message"
|
||||
TypePing MessageType = "ping"
|
||||
|
||||
// Client -> Server (lobby).
|
||||
TypeLobbyList MessageType = "lobby_list"
|
||||
TypeLobbyCreate MessageType = "lobby_create"
|
||||
TypeLobbyQuickPlay MessageType = "lobby_quick_play"
|
||||
TypeLobbyJoinRace MessageType = "lobby_join_race"
|
||||
TypeLobbyLeaveRace MessageType = "lobby_leave_race"
|
||||
TypeLobbyKickDriver MessageType = "lobby_kick_driver"
|
||||
TypeLobbyDelete MessageType = "lobby_delete"
|
||||
|
||||
// Client -> Server (catalog: tracks).
|
||||
TypeTrackList MessageType = "track_list"
|
||||
TypeTrackGet MessageType = "track_get"
|
||||
TypeTrackCreate MessageType = "track_create"
|
||||
TypeTrackUpdate MessageType = "track_update"
|
||||
TypeTrackDelete MessageType = "track_delete"
|
||||
|
||||
// Client -> Server (catalog: cars).
|
||||
TypeCarList MessageType = "car_list"
|
||||
TypeCarGet MessageType = "car_get"
|
||||
TypeCarCreate MessageType = "car_create"
|
||||
TypeCarUpdate MessageType = "car_update"
|
||||
TypeCarDelete MessageType = "car_delete"
|
||||
|
||||
// Client -> Server (stats).
|
||||
TypeStatsRequest MessageType = "stats_request"
|
||||
|
||||
// Server -> Client.
|
||||
TypeServerHello MessageType = "server_hello"
|
||||
TypeRaceSnapshot MessageType = "race_snapshot"
|
||||
TypeInputAck MessageType = "input_ack"
|
||||
TypeCorrection MessageType = "correction"
|
||||
TypeRaceEvent MessageType = "race_event"
|
||||
TypePong MessageType = "pong"
|
||||
TypeError MessageType = "error"
|
||||
|
||||
// Server -> Client (lobby).
|
||||
TypeLobbySnapshot MessageType = "lobby_snapshot"
|
||||
|
||||
// Server -> Client (catalog).
|
||||
TypeTrackSnapshot MessageType = "track_snapshot"
|
||||
TypeTrackAck MessageType = "track_ack"
|
||||
TypeCarSnapshot MessageType = "car_snapshot"
|
||||
TypeCarAck MessageType = "car_ack"
|
||||
TypeCatalogSummary MessageType = "catalog_summary"
|
||||
|
||||
// Server -> Client (stats).
|
||||
TypeStatsSnapshot MessageType = "stats_snapshot"
|
||||
TypeDriverProfile MessageType = "driver_profile"
|
||||
TypeLapRecorded MessageType = "lap_recorded"
|
||||
TypeRaceResult MessageType = "race_result"
|
||||
TypeRaceStandings MessageType = "race_standings"
|
||||
)
|
||||
|
||||
// Envelope wraps every WS frame with a type discriminator.
|
||||
type Envelope struct {
|
||||
Type MessageType `json:"type"`
|
||||
Seq uint64 `json:"seq,omitempty"`
|
||||
TSMs int64 `json:"ts_ms,omitempty"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
// ClientHello is the first message from a client.
|
||||
type ClientHello struct {
|
||||
Version string `json:"version"`
|
||||
Token string `json:"token,omitempty"` // empty in dev mode
|
||||
Locale string `json:"locale,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"` // for reconnect
|
||||
}
|
||||
|
||||
// ServerHello is the response to ClientHello.
|
||||
type ServerHello struct {
|
||||
SessionID string `json:"session_id"`
|
||||
RaceTick uint64 `json:"race_tick"`
|
||||
ServerVersion string `json:"server_version"`
|
||||
Config struct {
|
||||
TickRate int `json:"tick_rate_hz"`
|
||||
SnapshotRate int `json:"snapshot_rate_hz"`
|
||||
MaxCars int `json:"max_cars"`
|
||||
} `json:"config"`
|
||||
}
|
||||
|
||||
// JoinRace is sent by the client to enter a race session.
|
||||
type JoinRace struct {
|
||||
RaceID string `json:"race_id"`
|
||||
CarSlot int `json:"car_slot"` // 0-3 for PoC (4 cars)
|
||||
CarName string `json:"car_name,omitempty"`
|
||||
}
|
||||
|
||||
// LeaveRace is sent when leaving a race.
|
||||
type LeaveRace struct {
|
||||
RaceID string `json:"race_id"`
|
||||
}
|
||||
|
||||
// InputState is the high-rate control packet (60-120 Hz).
|
||||
type InputState struct {
|
||||
Steering float64 `json:"steering"` // -1.0 .. 1.0
|
||||
Throttle float64 `json:"throttle"` // 0.0 .. 1.0
|
||||
Brake float64 `json:"brake"` // 0.0 .. 1.0
|
||||
Gear int `json:"gear"` // -1 reverse, 0 neutral, 1..6 forward
|
||||
Buttons uint32 `json:"buttons,omitempty"`
|
||||
}
|
||||
|
||||
// ChatMessage for lobby chat (rate-limited).
|
||||
type ChatMessage struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Ping for latency measurement.
|
||||
type Ping struct {
|
||||
ClientTSMs int64 `json:"client_ts_ms"`
|
||||
}
|
||||
|
||||
// RaceSnapshot is the authoritative state of the race, broadcast N times/sec.
|
||||
type RaceSnapshot struct {
|
||||
Tick uint64 `json:"tick"`
|
||||
TSMs int64 `json:"ts_ms"`
|
||||
Elapsed float64 `json:"elapsed_s"`
|
||||
Cars []CarInfo `json:"cars"`
|
||||
}
|
||||
|
||||
// CarInfo describes one car in the race.
|
||||
type CarInfo struct {
|
||||
ID string `json:"id"`
|
||||
DriverName string `json:"driver_name"`
|
||||
X float64 `json:"x"` // metres
|
||||
Y float64 `json:"y"` // metres
|
||||
Heading float64 `json:"heading"` // radians
|
||||
Speed float64 `json:"speed"` // m/s
|
||||
Lap int `json:"lap"`
|
||||
Sector int `json:"sector"` // 0..2
|
||||
LastLapMs int64 `json:"last_lap_ms"`
|
||||
BestLapMs int64 `json:"best_lap_ms"`
|
||||
InputApplied struct {
|
||||
Steering float64 `json:"steering"`
|
||||
Throttle float64 `json:"throttle"`
|
||||
Brake float64 `json:"brake"`
|
||||
} `json:"input_applied"`
|
||||
DNF bool `json:"dnf"`
|
||||
}
|
||||
|
||||
// InputAck acknowledges an input packet (lightweight).
|
||||
type InputAck struct {
|
||||
Seq uint64 `json:"seq"`
|
||||
AppliedAtMs int64 `json:"applied_at_ms"`
|
||||
ServerTickMs int64 `json:"server_tick_ms"`
|
||||
}
|
||||
|
||||
// Correction is sent when client prediction diverged from server.
|
||||
type Correction struct {
|
||||
Tick uint64 `json:"tick"`
|
||||
DeltaSteering float64 `json:"delta_steering"`
|
||||
DeltaThrottle float64 `json:"delta_throttle"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// RaceEvent for major state transitions.
|
||||
type RaceEvent struct {
|
||||
RaceID string `json:"race_id"`
|
||||
Event string `json:"event"` // "start", "lap", "sector", "dnf", "finish"
|
||||
CarID string `json:"car_id,omitempty"`
|
||||
Sector int `json:"sector,omitempty"`
|
||||
LapMs int64 `json:"lap_ms,omitempty"`
|
||||
TSMs int64 `json:"ts_ms"`
|
||||
}
|
||||
|
||||
// Pong for RTT measurement.
|
||||
type Pong struct {
|
||||
ClientTSMs int64 `json:"client_ts_ms"`
|
||||
ServerTSMs int64 `json:"server_ts_ms"`
|
||||
}
|
||||
|
||||
// ErrorMsg is sent on protocol or validation errors.
|
||||
type ErrorMsg struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lobby payloads
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// LobbyRace is the wire form of lobby.RaceMeta (kept separate to avoid
|
||||
// a circular import between transport and lobby).
|
||||
type LobbyRace struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TrackID string `json:"track_id"`
|
||||
MaxCars int `json:"max_cars"`
|
||||
Laps int `json:"laps"`
|
||||
TimeLimitS int `json:"time_limit_s"`
|
||||
DriverIDs []string `json:"driver_ids"`
|
||||
Status string `json:"status"`
|
||||
CreatedMs int64 `json:"created_ms"`
|
||||
StartedMs int64 `json:"started_ms,omitempty"`
|
||||
// Podium is populated only for finished races. Empty for live ones.
|
||||
Podium []RacePodiumEntry `json:"podium,omitempty"`
|
||||
}
|
||||
|
||||
// RacePodiumEntry is one row of a finished race's top-3.
|
||||
type RacePodiumEntry struct {
|
||||
Position int `json:"position" example:"1"`
|
||||
DriverID string `json:"driver_id" example:"driver-alice"`
|
||||
Name string `json:"name" example:"driver-alice"`
|
||||
TotalTimeMs int64 `json:"total_time_ms" example:"123456"`
|
||||
}
|
||||
|
||||
// LobbyDriver is the wire form of lobby.DriverMeta.
|
||||
type LobbyDriver struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // idle | racing | offline
|
||||
RaceID string `json:"race_id,omitempty"`
|
||||
ConnectedMs int64 `json:"connected_ms"`
|
||||
LastSeenMs int64 `json:"last_seen_ms"`
|
||||
Country string `json:"country,omitempty"`
|
||||
AvatarHue int `json:"avatar_hue,omitempty"`
|
||||
}
|
||||
|
||||
// LobbySnapshot is the full lobby state, broadcast on change.
|
||||
type LobbySnapshot struct {
|
||||
GeneratedMs int64 `json:"generated_ms"`
|
||||
Version uint64 `json:"version"`
|
||||
Races []LobbyRace `json:"races"`
|
||||
Drivers []LobbyDriver `json:"drivers"`
|
||||
}
|
||||
|
||||
// LobbyCreate is sent by a client to create a race (becomes host).
|
||||
type LobbyCreate struct {
|
||||
RaceID string `json:"race_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
TrackID string `json:"track_id,omitempty"`
|
||||
MaxCars int `json:"max_cars,omitempty"`
|
||||
Laps int `json:"laps,omitempty"`
|
||||
TimeLimitS int `json:"time_limit_s,omitempty"`
|
||||
}
|
||||
|
||||
// LobbyJoinRace is sent by a client to join a specific race.
|
||||
type LobbyJoinRace struct {
|
||||
RaceID string `json:"race_id"`
|
||||
CarSlot int `json:"car_slot,omitempty"`
|
||||
CarName string `json:"car_name,omitempty"`
|
||||
}
|
||||
|
||||
// LobbyLeaveRace moves a driver from race back to lobby.
|
||||
type LobbyLeaveRace struct {
|
||||
RaceID string `json:"race_id,omitempty"`
|
||||
}
|
||||
|
||||
// LobbyKickDriver is sent by the host to remove another driver.
|
||||
type LobbyKickDriver struct {
|
||||
RaceID string `json:"race_id"`
|
||||
DriverID string `json:"driver_id"`
|
||||
}
|
||||
|
||||
// LobbyDelete removes the race entirely (host only).
|
||||
type LobbyDelete struct {
|
||||
RaceID string `json:"race_id"`
|
||||
}
|
||||
|
||||
// LobbyCreatedAck confirms a successful lobby_create.
|
||||
type LobbyCreatedAck struct {
|
||||
Ok bool `json:"ok"`
|
||||
Race LobbyRace `json:"race,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Driver LobbyDriver `json:"driver,omitempty"`
|
||||
}
|
||||
|
||||
// LobbyJoinAck confirms lobby_join_race / lobby_quick_play.
|
||||
type LobbyJoinAck struct {
|
||||
Ok bool `json:"ok"`
|
||||
Race LobbyRace `json:"race,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Catalog payloads (tracks and cars)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// TrackWaypoint is the wire form of catalog.Waypoint.
|
||||
type TrackWaypoint struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
HeadingRad float64 `json:"heading_rad"`
|
||||
SpeedMs float64 `json:"speed_ms"`
|
||||
Curvature float64 `json:"curvature"`
|
||||
}
|
||||
|
||||
// TrackBounds is the wire form of catalog.Bounds.
|
||||
type TrackBounds 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"`
|
||||
}
|
||||
|
||||
// TrackWire is the wire form of catalog.TrackMeta.
|
||||
type TrackWire struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
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 TrackBounds `json:"bounds"`
|
||||
Surface string `json:"surface"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Centerline []TrackWaypoint `json:"centerline"`
|
||||
BestLapMs int64 `json:"best_lap_ms"`
|
||||
BestLapHolder string `json:"best_lap_holder,omitempty"`
|
||||
CreatedMs int64 `json:"created_ms"`
|
||||
UpdatedMs int64 `json:"updated_ms"`
|
||||
}
|
||||
|
||||
// TrackListRequest: payload { "scope": "all|system|public|private", "tag": "..." }.
|
||||
type TrackListRequest struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
}
|
||||
|
||||
// TrackGetRequest: payload { "id": "..." }.
|
||||
type TrackGetRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// TrackCreateRequest: full TrackWire (server fills ID if empty).
|
||||
type TrackCreateRequest struct {
|
||||
Track TrackWire `json:"track"`
|
||||
}
|
||||
|
||||
// TrackUpdateRequest: ID + patch fields.
|
||||
type TrackUpdateRequest struct {
|
||||
ID string `json:"id"`
|
||||
Patch TrackPatch `json:"patch"`
|
||||
}
|
||||
|
||||
// TrackPatch: any field may be omitted (empty string / zero means unchanged).
|
||||
type TrackPatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Visibility *string `json:"visibility,omitempty"`
|
||||
Scale *int `json:"scale,omitempty"`
|
||||
LengthM *float64 `json:"length_m,omitempty"`
|
||||
WidthM *float64 `json:"width_m,omitempty"`
|
||||
LaneWidthM *float64 `json:"lane_width_m,omitempty"`
|
||||
Surface *string `json:"surface,omitempty"`
|
||||
Tags *[]string `json:"tags,omitempty"`
|
||||
Centerline *[]TrackWaypoint `json:"centerline,omitempty"`
|
||||
}
|
||||
|
||||
// TrackDeleteRequest: payload { "id": "..." }.
|
||||
type TrackDeleteRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// TrackSnapshot: full catalog tracks feed (broadcast on change).
|
||||
type TrackSnapshot struct {
|
||||
GeneratedMs int64 `json:"generated_ms"`
|
||||
Version uint64 `json:"version"`
|
||||
Tracks []TrackWire `json:"tracks"`
|
||||
}
|
||||
|
||||
// TrackAck: response to create/update/delete operations.
|
||||
type TrackAck struct {
|
||||
Ok bool `json:"ok"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Track TrackWire `json:"track,omitempty"`
|
||||
}
|
||||
|
||||
// MotorWire is the wire form of catalog.MotorSpec.
|
||||
type MotorWire struct {
|
||||
Kind string `json:"kind"`
|
||||
Class string `json:"class"`
|
||||
KV int `json:"kv"`
|
||||
PowerW float64 `json:"power_w"`
|
||||
}
|
||||
|
||||
// BatteryWire is the wire form of catalog.BatterySpec.
|
||||
type BatteryWire struct {
|
||||
VoltageV float64 `json:"voltage_v"`
|
||||
CapacityMah int `json:"capacity_mah"`
|
||||
Cells int `json:"cells"`
|
||||
Chemistry string `json:"chemistry"`
|
||||
}
|
||||
|
||||
// ChassisWire is the wire form of catalog.ChassisSpec.
|
||||
type ChassisWire struct {
|
||||
Model string `json:"model"`
|
||||
Material string `json:"material"`
|
||||
Printed bool `json:"printed"`
|
||||
WheelbaseMm float64 `json:"wheelbase_mm"`
|
||||
TrackMm float64 `json:"track_mm"`
|
||||
}
|
||||
|
||||
// CarWire is the wire form of catalog.CarMeta.
|
||||
type CarWire 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 ChassisWire `json:"chassis"`
|
||||
Motor MotorWire `json:"motor"`
|
||||
Battery BatteryWire `json:"battery"`
|
||||
Drive string `json:"drive"`
|
||||
|
||||
TopSpeedMs float64 `json:"top_speed_ms"`
|
||||
|
||||
ColorHex string `json:"color_hex"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
|
||||
Active bool `json:"active"`
|
||||
|
||||
TotalDistanceM float64 `json:"total_distance_m"`
|
||||
TotalRaces int `json:"total_races"`
|
||||
TotalLaps int `json:"total_laps"`
|
||||
BestLapMs int64 `json:"best_lap_ms"`
|
||||
|
||||
CreatedMs int64 `json:"created_ms"`
|
||||
UpdatedMs int64 `json:"updated_ms"`
|
||||
}
|
||||
|
||||
// CarListRequest: payload { "scope": "all|system|public|private", "owner_id": "..." }.
|
||||
type CarListRequest struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
}
|
||||
|
||||
// CarGetRequest: payload { "id": "..." }.
|
||||
type CarGetRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// CarCreateRequest: full CarWire (server fills ID if empty).
|
||||
type CarCreateRequest struct {
|
||||
Car CarWire `json:"car"`
|
||||
}
|
||||
|
||||
// CarUpdateRequest: ID + patch fields.
|
||||
type CarUpdateRequest struct {
|
||||
ID string `json:"id"`
|
||||
Patch CarPatch `json:"patch"`
|
||||
}
|
||||
|
||||
// CarPatch: any field may be omitted (nil pointer = unchanged).
|
||||
type CarPatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Visibility *string `json:"visibility,omitempty"`
|
||||
Scale *int `json:"scale,omitempty"`
|
||||
LengthMm *float64 `json:"length_mm,omitempty"`
|
||||
WidthMm *float64 `json:"width_mm,omitempty"`
|
||||
HeightMm *float64 `json:"height_mm,omitempty"`
|
||||
WeightG *float64 `json:"weight_g,omitempty"`
|
||||
Chassis *ChassisWire `json:"chassis,omitempty"`
|
||||
Motor *MotorWire `json:"motor,omitempty"`
|
||||
Battery *BatteryWire `json:"battery,omitempty"`
|
||||
Drive *string `json:"drive,omitempty"`
|
||||
TopSpeedMs *float64 `json:"top_speed_ms,omitempty"`
|
||||
ColorHex *string `json:"color_hex,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
}
|
||||
|
||||
// CarDeleteRequest: payload { "id": "..." }.
|
||||
type CarDeleteRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// CarSnapshot: full catalog cars feed (broadcast on change).
|
||||
type CarSnapshot struct {
|
||||
GeneratedMs int64 `json:"generated_ms"`
|
||||
Version uint64 `json:"version"`
|
||||
Cars []CarWire `json:"cars"`
|
||||
}
|
||||
|
||||
// CarAck: response to create/update/delete operations.
|
||||
type CarAck struct {
|
||||
Ok bool `json:"ok"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Car CarWire `json:"car,omitempty"`
|
||||
}
|
||||
|
||||
// CatalogSummary: lightweight counters (for header chips).
|
||||
type CatalogSummary struct {
|
||||
TracksTotal int `json:"tracks_total"`
|
||||
TracksSystem int `json:"tracks_system"`
|
||||
TracksPublic int `json:"tracks_public"`
|
||||
TracksPrivate int `json:"tracks_private"`
|
||||
CarsTotal int `json:"cars_total"`
|
||||
CarsSystem int `json:"cars_system"`
|
||||
CarsPublic int `json:"cars_public"`
|
||||
CarsPrivate int `json:"cars_private"`
|
||||
CarsActive int `json:"cars_active"`
|
||||
TSMs int64 `json:"ts_ms"`
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Races: list / upcoming / queue / plans
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// RaceListResponse is the keyset-paginated body returned by /api/races.
|
||||
type RaceListResponse struct {
|
||||
Items []LobbyRace `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty" example:"MTcwMDAwMDAwMDAwMDpyYWNlLWFiYzpmaW5pc2hlZA"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// RaceUpcomingItem is one row in the upcoming list.
|
||||
type RaceUpcomingItem struct {
|
||||
Meta LobbyRace `json:"meta"`
|
||||
QueueLen int `json:"queue_len"`
|
||||
PlanID string `json:"plan_id,omitempty"`
|
||||
StartAtMs int64 `json:"start_at_ms"`
|
||||
}
|
||||
|
||||
// RaceUpcomingResponse is the body returned by /api/races/upcoming.
|
||||
type RaceUpcomingResponse struct {
|
||||
Items []RaceUpcomingItem `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// RaceQueueEntry is one (driver, race) subscription.
|
||||
type RaceQueueEntry struct {
|
||||
DriverID string `json:"driver_id" example:"driver-42"`
|
||||
RaceID string `json:"race_id" example:"race-1730000-7"`
|
||||
PlanID string `json:"plan_id,omitempty" example:"plan-1730000-1"`
|
||||
EnqueuedMs int64 `json:"enqueued_ms" example:"1730000000000"`
|
||||
}
|
||||
|
||||
// RaceQueueJoinRequest is the body of POST /api/races/queue/join.
|
||||
// Either driver_id (body) or X-Driver-Id header is accepted.
|
||||
type RaceQueueJoinRequest struct {
|
||||
DriverID string `json:"driver_id" example:"driver-42"`
|
||||
Limit int `json:"limit,omitempty" example:"3"`
|
||||
}
|
||||
|
||||
// RaceQueueJoinResponse is what /api/races/queue/join returns.
|
||||
type RaceQueueJoinResponse struct {
|
||||
Joined []RaceQueueEntry `json:"joined"`
|
||||
Skipped []string `json:"skipped,omitempty" example:"race-1,race-2"`
|
||||
}
|
||||
|
||||
// RaceQueueListResponse is what GET /api/races/queue returns.
|
||||
type RaceQueueListResponse struct {
|
||||
Items []RaceQueueEntry `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// RacePlan is the wire form of races.RacePlan.
|
||||
type RacePlan struct {
|
||||
ID string `json:"id" example:"plan-1730000-1"`
|
||||
Name string `json:"name" example:"Daily Monza"`
|
||||
TrackID string `json:"track_id" example:"default"`
|
||||
MaxCars int `json:"max_cars" example:"4"`
|
||||
Laps int `json:"laps" example:"10"`
|
||||
TimeLimitS int `json:"time_limit_s,omitempty"`
|
||||
StartAtMs int64 `json:"start_at_ms" example:"1730000000000"`
|
||||
IntervalS int `json:"interval_s,omitempty" example:"3600"`
|
||||
Count int `json:"count,omitempty" example:"0"`
|
||||
Enabled bool `json:"enabled" example:"true"`
|
||||
CreatedMs int64 `json:"created_ms"`
|
||||
UpdatedMs int64 `json:"updated_ms"`
|
||||
NextFireMs int64 `json:"next_fire_ms"`
|
||||
FiresDone int `json:"fires_done"`
|
||||
}
|
||||
|
||||
// RacePlanCreateRequest is the body of POST /api/races/plans.
|
||||
type RacePlanCreateRequest struct {
|
||||
ID string `json:"id,omitempty" example:"plan-1730000-1"`
|
||||
Name string `json:"name" example:"Daily Monza"`
|
||||
TrackID string `json:"track_id,omitempty" example:"default"`
|
||||
MaxCars int `json:"max_cars" example:"4"`
|
||||
Laps int `json:"laps,omitempty" example:"10"`
|
||||
TimeLimitS int `json:"time_limit_s,omitempty"`
|
||||
StartAtMs int64 `json:"start_at_ms" example:"1730000000000"`
|
||||
IntervalS int `json:"interval_s,omitempty" example:"3600"`
|
||||
Count int `json:"count,omitempty" example:"0"`
|
||||
Enabled *bool `json:"enabled,omitempty" example:"true"`
|
||||
}
|
||||
|
||||
// RacePlanListResponse is the keyset-paginated body returned by GET /api/races/plans.
|
||||
type RacePlanListResponse struct {
|
||||
Items []RacePlan `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Drivers and clans
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Driver is the wire form of a driver record.
|
||||
type Driver struct {
|
||||
ID string `json:"id" example:"driver-1782000-123"`
|
||||
Nickname string `json:"nickname" example:"ACE"`
|
||||
Name string `json:"name" example:"Alice"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/u/ace.png"`
|
||||
ClanID string `json:"clan_id,omitempty" example:"clan-1782000-1"`
|
||||
CreatedMs int64 `json:"created_ms"`
|
||||
UpdatedMs int64 `json:"updated_ms"`
|
||||
}
|
||||
|
||||
// DriverCreateRequest is the body of POST /api/drivers.
|
||||
type DriverCreateRequest struct {
|
||||
ID string `json:"id,omitempty" example:"driver-1782000-123"`
|
||||
Nickname string `json:"nickname" example:"ACE"`
|
||||
Name string `json:"name" example:"Alice"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/u/ace.png"`
|
||||
ClanID string `json:"clan_id,omitempty" example:"clan-1782000-1"`
|
||||
}
|
||||
|
||||
// DriverUpdateRequest is the body of PUT /api/drivers/{id}.
|
||||
type DriverUpdateRequest struct {
|
||||
Name string `json:"name,omitempty" example:"Alice"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/u/ace.png"`
|
||||
ClanID *string `json:"clan_id,omitempty" example:"clan-1782000-1"`
|
||||
}
|
||||
|
||||
// DriverListResponse is the body returned by GET /api/drivers.
|
||||
type DriverListResponse struct {
|
||||
Items []Driver `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// Clan is the wire form of a clan record.
|
||||
type Clan struct {
|
||||
ID string `json:"id" example:"clan-1782000-1"`
|
||||
Tag string `json:"tag" example:"ACE"`
|
||||
Name string `json:"name" example:"Ace Racing"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/c/ace.png"`
|
||||
CreatedMs int64 `json:"created_ms"`
|
||||
UpdatedMs int64 `json:"updated_ms"`
|
||||
}
|
||||
|
||||
// ClanCreateRequest is the body of POST /api/clans.
|
||||
type ClanCreateRequest struct {
|
||||
ID string `json:"id,omitempty" example:"clan-1782000-1"`
|
||||
Tag string `json:"tag" example:"ACE"`
|
||||
Name string `json:"name" example:"Ace Racing"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/c/ace.png"`
|
||||
}
|
||||
|
||||
// ClanUpdateRequest is the body of PUT /api/clans/{id}.
|
||||
type ClanUpdateRequest struct {
|
||||
Name string `json:"name,omitempty" example:"Ace Racing"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/c/ace.png"`
|
||||
}
|
||||
|
||||
// ClanListResponse is the body returned by GET /api/clans.
|
||||
type ClanListResponse struct {
|
||||
Items []Clan `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Stats payloads
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// StatsServer mirrors stats.ServerStats for the wire.
|
||||
type StatsServer 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"`
|
||||
}
|
||||
|
||||
// StatsDriver mirrors stats.DriverProfile for the wire.
|
||||
type StatsDriver 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"`
|
||||
}
|
||||
|
||||
// StatsLap is one lap in the recent-laps feed.
|
||||
type StatsLap 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"`
|
||||
}
|
||||
|
||||
// StatsRaceResult is the final classification of a race for one driver.
|
||||
type StatsRaceResult 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"`
|
||||
}
|
||||
|
||||
// StatsSnapshot is the full server-side stats payload.
|
||||
type StatsSnapshot struct {
|
||||
Server StatsServer `json:"server"`
|
||||
Drivers []StatsDriver `json:"drivers"`
|
||||
Laps []StatsLap `json:"laps"`
|
||||
Results []StatsRaceResult `json:"results"`
|
||||
TSMs int64 `json:"ts_ms"`
|
||||
}
|
||||
|
||||
// StatsRequest is sent by clients to request a stats snapshot.
|
||||
// Scope: "server" | "driver" | "race".
|
||||
type StatsRequest struct {
|
||||
Scope string `json:"scope"`
|
||||
TargetID string `json:"target_id,omitempty"`
|
||||
}
|
||||
|
||||
// RaceStanding is one row in mid-race standings.
|
||||
type RaceStanding struct {
|
||||
Position int `json:"position"`
|
||||
CarID string `json:"car_id"`
|
||||
DriverID string `json:"driver_id"`
|
||||
DriverName string `json:"driver_name"`
|
||||
Lap int `json:"lap"`
|
||||
Sector int `json:"sector"`
|
||||
BestLapMs int64 `json:"best_lap_ms"`
|
||||
LastLapMs int64 `json:"last_lap_ms"`
|
||||
GapMs int64 `json:"gap_ms"`
|
||||
DNF bool `json:"dnf"`
|
||||
}
|
||||
|
||||
// RaceStandingsMsg is broadcast periodically during a race.
|
||||
type RaceStandingsMsg struct {
|
||||
RaceID string `json:"race_id"`
|
||||
Tick uint64 `json:"tick"`
|
||||
TSMs int64 `json:"ts_ms"`
|
||||
Lap int `json:"lap"` // leader lap
|
||||
Elapsed float64 `json:"elapsed_s"`
|
||||
Rows []RaceStanding `json:"rows"`
|
||||
}
|
||||
|
||||
// LapRecordedMsg is sent when a driver completes a lap.
|
||||
type LapRecordedMsg struct {
|
||||
RaceID string `json:"race_id"`
|
||||
DriverID string `json:"driver_id"`
|
||||
Lap int `json:"lap"`
|
||||
LapMs int64 `json:"lap_ms"`
|
||||
Sector1Ms int64 `json:"sector1_ms"`
|
||||
Sector2Ms int64 `json:"sector2_ms"`
|
||||
Sector3Ms int64 `json:"sector3_ms"`
|
||||
TSMs int64 `json:"ts_ms"`
|
||||
Position int `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
// RaceResultMsg is sent for each driver when a race finishes.
|
||||
type RaceResultMsg struct {
|
||||
RaceID string `json:"race_id"`
|
||||
DriverID string `json:"driver_id"`
|
||||
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"`
|
||||
FinishedAtMs int64 `json:"finished_at_ms"`
|
||||
}
|
||||
|
||||
// Encode marshals an envelope to JSON bytes.
|
||||
func Encode(env *Envelope) ([]byte, error) {
|
||||
if env.TSMs == 0 {
|
||||
env.TSMs = time.Now().UnixMilli()
|
||||
}
|
||||
return json.Marshal(env)
|
||||
}
|
||||
|
||||
// Decode unmarshals JSON bytes into an envelope (payload kept as json.RawMessage).
|
||||
func Decode(data []byte) (*Envelope, error) {
|
||||
var env Envelope
|
||||
if err := json.Unmarshal(data, &env); err != nil {
|
||||
return nil, fmt.Errorf("decode envelope: %w", err)
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
Reference in New Issue
Block a user