3 Commits
26 changed files with 4121 additions and 44 deletions
+149 -9
View File
@@ -17,9 +17,11 @@
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
@@ -73,7 +75,7 @@ func boundsToWire(b catalog.Bounds) transport.TrackBounds {
}
}
func trackToWire(t catalog.TrackMeta) transport.TrackWire {
func trackToWire(t catalog.TrackMeta, activeTracks map[string]bool) transport.TrackWire {
return transport.TrackWire{
ID: t.ID,
Name: t.Name,
@@ -92,20 +94,36 @@ func trackToWire(t catalog.TrackMeta) transport.TrackWire {
BestLapHolder: t.BestLapHolder,
CreatedMs: t.CreatedMs,
UpdatedMs: t.UpdatedMs,
IsActive: activeTracks[t.ID],
}
}
func tracksToWire(in []catalog.TrackMeta) []transport.TrackWire {
func tracksToWire(in []catalog.TrackMeta, activeTracks map[string]bool) []transport.TrackWire {
if len(in) == 0 {
return []transport.TrackWire{}
}
out := make([]transport.TrackWire, len(in))
for i, t := range in {
out[i] = trackToWire(t)
out[i] = trackToWire(t, activeTracks)
}
return out
}
func getActiveTracks(ctx context.Context, cat *catalog.Service) map[string]bool {
active := make(map[string]bool)
calendar, err := cat.GetTrackCalendar(ctx)
if err != nil {
return active
}
now := time.Now()
for _, entry := range calendar {
if (now.Equal(entry.StartAt) || now.After(entry.StartAt)) && now.Before(entry.EndAt) {
active[entry.TrackID] = true
}
}
return active
}
func trackFromWire(w transport.TrackWire) catalog.TrackMeta {
return catalog.TrackMeta{
ID: w.ID,
@@ -400,10 +418,11 @@ func mapCatalogError(err error) (int, string) {
// @Failure 500 {object} map[string]interface{} "Internal server error"
// @Router /api/catalog [get]
func catalogHandler(svc *catalog.Service) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
snap := svc.Snapshot()
activeTracks := getActiveTracks(r.Context(), svc)
writeJSON(w, http.StatusOK, map[string]any{
"tracks": tracksToWire(snap.Tracks),
"tracks": tracksToWire(snap.Tracks, activeTracks),
"cars": carsToWire(snap.Cars),
"summary": summaryToWire(svc.Stats()),
})
@@ -439,8 +458,9 @@ func tracksHandler(svc *catalog.Service) http.HandlerFunc {
if tag != "" {
out = filterTracksByTag(out, tag)
}
activeTracks := getActiveTracks(r.Context(), svc)
writeJSON(w, http.StatusOK, map[string]any{
"tracks": tracksToWire(out),
"tracks": tracksToWire(out, activeTracks),
"count": len(out),
})
}
@@ -471,6 +491,7 @@ func trackByIDHandler(svc *catalog.Service) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "bad_request", "missing track id")
return
}
activeTracks := getActiveTracks(r.Context(), svc)
switch r.Method {
case http.MethodGet:
t, ok := svc.GetTrack(id)
@@ -478,7 +499,7 @@ func trackByIDHandler(svc *catalog.Service) http.HandlerFunc {
writeError(w, http.StatusNotFound, "not_found", "track not found")
return
}
writeJSON(w, http.StatusOK, trackToWire(t))
writeJSON(w, http.StatusOK, trackToWire(t, activeTracks))
case http.MethodPut:
var req transport.TrackUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -495,7 +516,7 @@ func trackByIDHandler(svc *catalog.Service) http.HandlerFunc {
writeError(w, status, code, err.Error())
return
}
writeJSON(w, http.StatusOK, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated)})
writeJSON(w, http.StatusOK, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated, activeTracks)})
case http.MethodDelete:
if err := svc.DeleteTrack(r.Context(), id); err != nil {
status, code := mapCatalogError(err)
@@ -549,7 +570,8 @@ func createTrackHandler(svc *catalog.Service) http.HandlerFunc {
writeError(w, status, code, err.Error())
return
}
writeJSON(w, http.StatusCreated, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created)})
activeTracks := getActiveTracks(r.Context(), svc)
writeJSON(w, http.StatusCreated, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created, activeTracks)})
}
}
@@ -734,3 +756,121 @@ func carsRouter(svc *catalog.Service) http.HandlerFunc {
}
}
}
// trackCalendarRouter routes /api/tracks/calendar to GET or POST
func trackCalendarRouter(cat *catalog.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
getTrackCalendarHandler(cat)(w, r)
case http.MethodPost:
createTrackCalendarHandler(cat)(w, r)
default:
w.Header().Set("Allow", "GET, POST")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST")
}
}
}
// getTrackCalendarHandler godoc
// @Summary Get Track Calendar
// @Description Returns the full calendar of track active periods.
// @Tags tracks
// @Produce json
// @Success 200 {object} transport.TrackCalendarResponse "List of scheduled active periods"
// @Failure 500 {object} map[string]interface{} "Internal error"
// @Router /api/tracks/calendar [get]
func getTrackCalendarHandler(cat *catalog.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
entries, err := cat.GetTrackCalendar(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
items := make([]transport.TrackCalendarEntry, len(entries))
for i, entry := range entries {
items[i] = transport.TrackCalendarEntry{
ID: entry.ID,
TrackID: entry.TrackID,
StartAt: entry.StartAt,
EndAt: entry.EndAt,
}
}
writeJSON(w, http.StatusOK, transport.TrackCalendarResponse{Items: items})
}
}
// createTrackCalendarHandler godoc
// @Summary Schedule Track Active Period
// @Description Schedules an active period for a physical track.
// @Tags tracks
// @Accept json
// @Produce json
// @Param payload body transport.TrackCalendarCreateRequest true "Calendar entry details"
// @Success 201 {object} transport.TrackCalendarEntry "Created calendar entry"
// @Failure 400 {object} map[string]interface{} "Invalid input"
// @Failure 404 {object} map[string]interface{} "Track not found"
// @Failure 500 {object} map[string]interface{} "Internal error"
// @Router /api/tracks/calendar [post]
func createTrackCalendarHandler(cat *catalog.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req transport.TrackCalendarCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
entry, err := cat.CreateTrackCalendar(r.Context(), req.TrackID, req.StartAt, req.EndAt)
if err != nil {
if errors.Is(err, catalog.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
if errors.Is(err, catalog.ErrInvalidInput) {
writeError(w, http.StatusBadRequest, "invalid_input", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusCreated, transport.TrackCalendarEntry{
ID: entry.ID,
TrackID: entry.TrackID,
StartAt: entry.StartAt,
EndAt: entry.EndAt,
})
}
}
// deleteTrackCalendarHandler godoc
// @Summary Delete Scheduled Period
// @Description Deletes a scheduled active period by ID.
// @Tags tracks
// @Param id path int true "Calendar Entry ID"
// @Success 204 "No Content"
// @Failure 404 {object} map[string]interface{} "Not found"
// @Failure 500 {object} map[string]interface{} "Internal error"
// @Router /api/tracks/calendar/{id} [delete]
func deleteTrackCalendarHandler(cat *catalog.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/api/tracks/calendar/")
id, err := strconv.Atoi(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id", "id must be an integer")
return
}
err = cat.DeleteTrackCalendar(r.Context(), id)
if err != nil {
if errors.Is(err, catalog.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+10 -5
View File
@@ -84,12 +84,13 @@ func (cb *catalogBroadcaster) broadcast(ev catalog.Event) {
return
case catalog.EventTrackUpsert, catalog.EventTrackDelete:
snap := cb.svc.Snapshot()
activeTracks := getActiveTracks(context.Background(), cb.svc)
cb.hub.Publish(&transport.Envelope{
Type: transport.TypeTrackSnapshot,
Payload: transport.TrackSnapshot{
GeneratedMs: snap.GeneratedMs,
Version: snap.Version,
Tracks: tracksToWire(snap.Tracks),
Tracks: tracksToWire(snap.Tracks, activeTracks),
},
})
cb.hub.Publish(&transport.Envelope{
@@ -139,10 +140,11 @@ func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transpo
out = filterTracksByTag(out, req.Tag)
}
snap := svc.Snapshot()
activeTracks := getActiveTracks(wsCtx(), svc)
sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{
GeneratedMs: snap.GeneratedMs,
Version: snap.Version,
Tracks: tracksToWire(out),
Tracks: tracksToWire(out, activeTracks),
})
case transport.TypeTrackGet:
var req transport.TrackGetRequest
@@ -156,8 +158,9 @@ func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transpo
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: "not_found"})
return
}
activeTracks := getActiveTracks(wsCtx(), svc)
sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{
Tracks: []transport.TrackWire{trackToWire(t)},
Tracks: []transport.TrackWire{trackToWire(t, activeTracks)},
})
case transport.TypeTrackCreate:
var req transport.TrackCreateRequest
@@ -173,7 +176,8 @@ func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transpo
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, Error: err.Error()})
return
}
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created)})
activeTracks := getActiveTracks(wsCtx(), svc)
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created, activeTracks)})
case transport.TypeTrackUpdate:
var req transport.TrackUpdateRequest
_ = json.Unmarshal(payloadBytes, &req)
@@ -183,7 +187,8 @@ func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transpo
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: err.Error()})
return
}
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated)})
activeTracks := getActiveTracks(wsCtx(), svc)
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated, activeTracks)})
case transport.TypeTrackDelete:
var req transport.TrackDeleteRequest
_ = json.Unmarshal(payloadBytes, &req)
@@ -0,0 +1,183 @@
package main
import (
"net/http"
"strconv"
"github.com/x0gp/server/internal/races"
"github.com/x0gp/server/internal/transport"
)
// leaderboardHandler godoc
// @Summary Leaderboard
// @Description Returns paginated leaderboard list. Supports overall leaderboard or track-specific leaderboard for the current year.
// @Tags leaderboard
// @Produce json
// @Param track_id query string false "Track ID. If empty, returns overall leaderboard."
// @Param year query int false "Year. Defaults to current year."
// @Param limit query int false "Limit (1..200, default 50)"
// @Param offset query int false "Offset (default 0)"
// @Param X-Driver-Id header string false "Current driver ID. Defaults to driver-seed-001 (mock Alice)."
// @Success 200 {object} transport.LeaderboardResponse "Leaderboard page and current driver position"
// @Failure 400 {object} map[string]interface{} "Invalid input"
// @Failure 500 {object} map[string]interface{} "Internal server error"
// @Router /api/leaderboard [get]
func leaderboardHandler(svc *races.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
trackID := r.URL.Query().Get("track_id")
yearVal := r.URL.Query().Get("year")
var year int
if yearVal != "" {
var err error
year, err = strconv.Atoi(yearVal)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_input", "invalid year")
return
}
}
limit := 50
limitVal := r.URL.Query().Get("limit")
if limitVal != "" {
var err error
limit, err = strconv.Atoi(limitVal)
if err != nil || limit <= 0 {
writeError(w, http.StatusBadRequest, "invalid_input", "invalid limit")
return
}
if limit > 200 {
limit = 200
}
}
offset := 0
offsetVal := r.URL.Query().Get("offset")
if offsetVal != "" {
var err error
offset, err = strconv.Atoi(offsetVal)
if err != nil || offset < 0 {
writeError(w, http.StatusBadRequest, "invalid_input", "invalid offset")
return
}
}
driverID := r.Header.Get("X-Driver-Id")
if driverID == "" {
driverID = "driver-seed-001" // mock current driver
}
res, err := svc.GetLeaderboard(r.Context(), trackID, year, limit, offset, driverID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
items := make([]transport.LeaderboardEntry, 0, len(res.Items))
for _, item := range res.Items {
items = append(items, leaderboardEntryToWire(item))
}
var curDriver *transport.LeaderboardEntry
if res.CurrentDriver != nil {
wire := leaderboardEntryToWire(*res.CurrentDriver)
curDriver = &wire
}
writeJSON(w, http.StatusOK, transport.LeaderboardResponse{
Items: items,
Total: res.Total,
Limit: limit,
Offset: offset,
CurrentDriver: curDriver,
})
}
}
func leaderboardEntryToWire(e races.LeaderboardEntry) transport.LeaderboardEntry {
var bestPos *int
if e.BestPos != nil {
pos := *e.BestPos
bestPos = &pos
}
var bestTime *int64
if e.BestTimeMs != nil {
val := *e.BestTimeMs
bestTime = &val
}
return transport.LeaderboardEntry{
DriverID: e.DriverID,
Nickname: e.Nickname,
Name: e.Name,
AvatarURL: e.AvatarURL,
ClanID: e.ClanID,
ClanTag: e.ClanTag,
Points: e.Points,
BestPos: bestPos,
BestTimeMs: bestTime,
Rank: e.Rank,
}
}
// calendarLeaderboardHandler godoc
// @Summary Track Calendar Leaderboards
// @Description Returns the list of scheduled tracks with their status (finished/active/planned), top 3 drivers, and current driver position, sorted by calendar start date.
// @Tags leaderboard
// @Produce json
// @Param year query int false "Year. Defaults to current year."
// @Param X-Driver-Id header string false "Current driver ID. Defaults to driver-seed-001 (mock Alice)."
// @Success 200 {object} transport.TrackCalendarLeaderboardResponse "Calendar leaderboards list"
// @Failure 400 {object} map[string]interface{} "Invalid input"
// @Failure 500 {object} map[string]interface{} "Internal server error"
// @Router /api/leaderboard/tracks [get]
func calendarLeaderboardHandler(svc *races.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
yearVal := r.URL.Query().Get("year")
var year int
if yearVal != "" {
var err error
year, err = strconv.Atoi(yearVal)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_input", "invalid year")
return
}
}
driverID := r.Header.Get("X-Driver-Id")
if driverID == "" {
driverID = "driver-seed-001" // mock current driver
}
res, err := svc.GetCalendarLeaderboard(r.Context(), year, driverID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
items := make([]transport.TrackCalendarLeaderboard, 0, len(res))
for _, item := range res {
podium := make([]transport.LeaderboardEntry, 0, len(item.Podium))
for _, entry := range item.Podium {
podium = append(podium, leaderboardEntryToWire(entry))
}
var curDriver *transport.LeaderboardEntry
if item.CurrentDriver != nil {
wire := leaderboardEntryToWire(*item.CurrentDriver)
curDriver = &wire
}
items = append(items, transport.TrackCalendarLeaderboard{
TrackID: item.TrackID,
TrackName: item.TrackName,
StartAt: item.StartAt,
EndAt: item.EndAt,
Status: item.Status,
Podium: podium,
CurrentDriver: curDriver,
})
}
writeJSON(w, http.StatusOK, transport.TrackCalendarLeaderboardResponse{Items: items})
}
}
+32 -11
View File
@@ -184,7 +184,7 @@ func main() {
// Restore the in-memory lobby from Postgres so active races and
// driver presence survive a restart.
restoreCtx, restoreCancel := context.WithTimeout(ctx, 5*time.Second)
restoreCtx, restoreCancel := context.WithTimeout(ctx, 15*time.Second)
if racesRestored, driversRestored, err := racesSvc.RestoreFromDB(restoreCtx); err != nil {
logger.Warn("restore from db failed", "err", err)
} else {
@@ -196,6 +196,9 @@ func main() {
clansSvc := clans.NewService(clans.NewPgStore(pool))
driversSvc := drivers.NewService(drivers.NewPgStore(pool))
// WebRTC 1-to-1 driver-car proxy service
webrtcSvc := NewWebRTCService(logger, engine, lobbySvc, videoSvc)
// Persist finished races so the /api/races list and keyset pagination
// can serve historical data across restarts. The snapshot is best-
// effort: errors are logged but never block the lobby.
@@ -280,6 +283,9 @@ func main() {
// UDP video and command receiver.
videoSvc.Start(ctx, &wg)
// Start WebRTC service
webrtcSvc.Start(ctx, &wg)
mux := http.NewServeMux()
mux.HandleFunc("/health", healthHandler(hub, engine))
mux.HandleFunc("/api/version", versionHandler())
@@ -287,6 +293,8 @@ func main() {
mux.HandleFunc("/api/catalog", catalogHandler(cat))
mux.HandleFunc("/api/tracks", tracksRouter(cat))
mux.HandleFunc("/api/tracks/", trackByIDHandler(cat))
mux.HandleFunc("/api/tracks/calendar", trackCalendarRouter(cat))
mux.HandleFunc("/api/tracks/calendar/", deleteTrackCalendarHandler(cat))
mux.HandleFunc("/api/cars", carsRouter(cat))
mux.HandleFunc("/api/cars/", carByIDHandler(cat))
mux.HandleFunc("/api/races", racesListHandler(racesSvc))
@@ -295,14 +303,20 @@ func main() {
mux.HandleFunc("/api/races/queue/join", racesQueueJoinHandler(racesSvc))
mux.HandleFunc("/api/races/plans", racePlansRouter(racesSvc))
mux.HandleFunc("/api/races/plans/", racePlansDeleteHandler(racesSvc))
mux.HandleFunc("/api/leaderboard", leaderboardHandler(racesSvc))
mux.HandleFunc("/api/leaderboard/tracks", calendarLeaderboardHandler(racesSvc))
mux.HandleFunc("/api/clans", clansRouter(clansSvc))
mux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc))
mux.HandleFunc("/api/drivers", driversRouter(driversSvc))
mux.HandleFunc("/api/drivers/", driversByIDHandler(driversSvc, lobbySvc))
mux.HandleFunc("/api/video/stream", videoSvc.StreamHandler())
mux.HandleFunc("/api/video/control", videoSvc.ControlHandler())
mux.HandleFunc("/api/webrtc/connect", webrtcSvc.ConnectHandler())
mux.HandleFunc("/ws", wsHandler(cfg, engine, hub, cat, lobbySvc, driversSvc, videoSvc, logger))
// Static client files (resolving TODO)
mux.Handle("/", http.FileServer(http.Dir("./web")))
// Swagger UI + OpenAPI spec.
// UI: GET /swagger/index.html
// Spec: GET /swagger/doc.json
@@ -561,7 +575,7 @@ func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *c
case transport.TypeClientHello:
handleClientHello(c, e, cfg, lobbySvc, driversSvc, env)
case transport.TypeJoinRace:
handleJoinRace(c, e, env)
handleJoinRace(c, e, lobbySvc, env)
case transport.TypeLeaveRace:
handleLeaveRace(c, e, env)
case transport.TypeInputState:
@@ -619,7 +633,7 @@ func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config
c.Send <- mustEncode(transport.TypeServerHello, hello)
}
func handleJoinRace(c *realtime.Client, e *control.Engine, env *transport.Envelope) {
func handleJoinRace(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service, env *transport.Envelope) {
payload, _ := env.Payload.(map[string]any)
slot := 0
name := "anon"
@@ -631,7 +645,18 @@ func handleJoinRace(c *realtime.Client, e *control.Engine, env *transport.Envelo
name = v
}
}
car, err := e.AddCar(c.ID, name, slot)
// Resolve DeviceID for this driver internally
driverID := c.DriverID
if driverID == "" {
driverID = c.ID
}
var deviceID *int
if d, err := lobbySvc.GetDriver(driverID); err == nil {
deviceID = d.DeviceID
}
car, err := e.AddCar(c.ID, name, slot, deviceID)
if err != nil {
sendError(c, "join_failed", err.Error())
return
@@ -666,12 +691,8 @@ func handleInput(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service,
}
car.ApplyInput(in, 1.0/60.0)
// Forward command transitions to ESP32 physical car if driver is assigned one
driverID := c.DriverID
if driverID == "" {
driverID = c.ID
}
if d, err := lobbySvc.GetDriver(driverID); err == nil && d.DeviceID != nil {
// Forward command transitions to ESP32 physical car if car has a device_id assigned
if car.DeviceID != nil {
var cmd string
if in.Throttle > 0.1 {
cmd = "start"
@@ -686,7 +707,7 @@ func handleInput(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service,
if cmd != "" && cmd != c.LastUDPCommand {
c.LastUDPCommand = cmd
_ = videoSvc.SendCommand(uint8(*d.DeviceID), cmd)
_ = videoSvc.SendCommand(uint8(*car.DeviceID), cmd)
}
}
+386
View File
@@ -0,0 +1,386 @@
package main
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"sync"
"time"
"github.com/pion/webrtc/v4"
"github.com/x0gp/server/internal/control"
"github.com/x0gp/server/internal/lobby"
"github.com/x0gp/server/internal/transport"
)
type driverSession struct {
driverID string
deviceID uint8
pc *webrtc.PeerConnection
videoDC *webrtc.DataChannel
dataDC *webrtc.DataChannel
lastCommand string
cancelCtx context.CancelFunc
}
type WebRTCService struct {
logger *slog.Logger
engine *control.Engine
lobbySvc *lobby.Service
videoSvc *UDPVideoService
sessions map[string]*driverSession
sessionsMu sync.RWMutex
}
func NewWebRTCService(logger *slog.Logger, e *control.Engine, lobbySvc *lobby.Service, videoSvc *UDPVideoService) *WebRTCService {
return &WebRTCService{
logger: logger,
engine: e,
lobbySvc: lobbySvc,
videoSvc: videoSvc,
sessions: make(map[string]*driverSession),
}
}
func (s *WebRTCService) Start(ctx context.Context, wg *sync.WaitGroup) {
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
s.CloseAll()
return
case <-ticker.C:
s.broadcastTelemetry()
}
}
}()
}
// ConnectHandler godoc
// @Summary Establish WebRTC connection for driver-car 1-to-1 proxying
// @Description Accepts SDP Offer, negotiates peer connection, and returns SDP Answer. Routes video and control data via DataChannels.
// @Tags webrtc
// @Accept json
// @Produce json
// @Param body body transport.WebRTCConnectRequest true "SDP Offer details"
// @Success 200 {object} transport.WebRTCConnectResponse "SDP Answer negotiation success"
// @Router /api/webrtc/connect [post]
func (s *WebRTCService) ConnectHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed")
return
}
var req transport.WebRTCConnectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request JSON")
return
}
if req.DriverID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "driver_id is required")
return
}
// Resolve device ID for this driver
deviceID := uint8(1) // default fallback
if driver, err := s.lobbySvc.GetDriver(req.DriverID); err == nil && driver.DeviceID != nil {
deviceID = uint8(*driver.DeviceID)
} else {
s.logger.Warn("webrtc client connected but no physical device_id assigned in lobbySvc. Defaulting to device 1", "driver_id", req.DriverID)
}
// Create PeerConnection
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
pc, err := webrtc.NewPeerConnection(config)
if err != nil {
s.logger.Error("failed to create webrtc peer connection", "err", err)
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
// Prepare context for background video pumping
connCtx, connCancel := context.WithCancel(context.Background())
session := &driverSession{
driverID: req.DriverID,
deviceID: deviceID,
pc: pc,
cancelCtx: connCancel,
}
// Handle Data Channels
pc.OnDataChannel(func(d *webrtc.DataChannel) {
s.logger.Info("received remote data channel", "label", d.Label(), "driver_id", req.DriverID)
s.sessionsMu.Lock()
if d.Label() == "video" {
session.videoDC = d
} else if d.Label() == "data" {
session.dataDC = d
}
s.sessionsMu.Unlock()
d.OnOpen(func() {
s.logger.Info("data channel opened", "label", d.Label(), "driver_id", req.DriverID)
if d.Label() == "video" {
// Start pumping video frames from UDPVideoService to the data channel
go s.pumpVideo(connCtx, session)
}
})
d.OnMessage(func(msg webrtc.DataChannelMessage) {
if d.Label() == "data" {
s.handleDataChannelMessage(session, msg.Data)
}
})
})
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
s.logger.Info("webrtc connection state changed", "state", state.String(), "driver_id", req.DriverID)
if state == webrtc.PeerConnectionStateClosed || state == webrtc.PeerConnectionStateFailed {
s.CloseSession(req.DriverID)
}
})
// Set the remote SessionDescription
err = pc.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeOffer,
SDP: req.SDP,
})
if err != nil {
pc.Close()
connCancel()
s.logger.Error("failed to set remote description", "err", err)
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
// Create Answer
answer, err := pc.CreateAnswer(nil)
if err != nil {
pc.Close()
connCancel()
s.logger.Error("failed to create answer", "err", err)
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
// Set Local Description
err = pc.SetLocalDescription(answer)
if err != nil {
pc.Close()
connCancel()
s.logger.Error("failed to set local description", "err", err)
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
// Wait for ICE Gathering to complete (Vanilla ICE)
gatherComplete := webrtc.GatheringCompletePromise(pc)
<-gatherComplete
// Save Session
s.sessionsMu.Lock()
// Clean up existing session if any
if existing, ok := s.sessions[req.DriverID]; ok {
existing.pc.Close()
existing.cancelCtx()
}
s.sessions[req.DriverID] = session
s.sessionsMu.Unlock()
localDesc := pc.LocalDescription()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(transport.WebRTCConnectResponse{
SDP: localDesc.SDP,
Type: "answer",
})
}
}
func (s *WebRTCService) pumpVideo(ctx context.Context, session *driverSession) {
s.logger.Info("starting WebRTC video pump for driver", "driver_id", session.driverID, "device_id", session.deviceID)
frameCh := make(chan []byte, 10)
s.videoSvc.registerClient(session.deviceID, frameCh)
defer func() {
s.videoSvc.unregisterClient(session.deviceID, frameCh)
s.logger.Info("stopping WebRTC video pump for driver", "driver_id", session.driverID)
}()
for {
select {
case <-ctx.Done():
return
case frame, ok := <-frameCh:
if !ok {
return
}
s.sessionsMu.RLock()
dc := session.videoDC
s.sessionsMu.RUnlock()
if dc != nil && dc.ReadyState() == webrtc.DataChannelStateOpen {
if err := dc.Send(frame); err != nil {
s.logger.Debug("failed to send video frame over datachannel", "driver_id", session.driverID, "err", err)
}
}
}
}
}
func (s *WebRTCService) handleDataChannelMessage(session *driverSession, data []byte) {
var payload map[string]any
if err := json.Unmarshal(data, &payload); err != nil {
s.logger.Warn("received invalid JSON on data channel", "driver_id", session.driverID, "err", err)
return
}
// 1. Steering & Throttle inputs
in := transport.InputState{}
hasInput := false
if v, ok := payload["throttle"].(float64); ok {
in.Throttle = v
hasInput = true
}
if v, ok := payload["steering"].(float64); ok {
in.Steering = v
hasInput = true
}
if v, ok := payload["brake"].(float64); ok {
in.Brake = v
hasInput = true
}
if v, ok := payload["gear"].(float64); ok {
in.Gear = int(v)
hasInput = true
}
if hasInput {
s.logger.Debug("received inputs on webrtc data channel", "driver_id", session.driverID, "throttle", in.Throttle, "steering", in.Steering)
car := s.engine.GetCarByDriver(session.driverID)
if car != nil {
car.ApplyInput(in, 1.0/60.0)
}
// Send commands to physical car
var cmd string
if in.Throttle > 0.1 {
cmd = "start"
} else {
cmd = "stop"
}
if in.Steering < -0.2 {
cmd = "left"
} else if in.Steering > 0.2 {
cmd = "right"
}
// Resolve destination device
var destDeviceID *uint8
if car != nil && car.DeviceID != nil {
val := uint8(*car.DeviceID)
destDeviceID = &val
} else if session.deviceID != 0 {
// Fallback to connection-time device
destDeviceID = &session.deviceID
}
if destDeviceID != nil && cmd != "" && cmd != session.lastCommand {
s.logger.Info("webrtc control state transition", "driver_id", session.driverID, "old_cmd", session.lastCommand, "new_cmd", cmd)
session.lastCommand = cmd
err := s.videoSvc.SendCommand(*destDeviceID, cmd)
if err != nil {
s.logger.Error("failed to forward command to ESP32", "device_id", *destDeviceID, "cmd", cmd, "err", err)
} else {
s.logger.Info("forwarded command to ESP32 via UDP", "device_id", *destDeviceID, "cmd", cmd)
}
}
}
// Echo back message / send ACK
s.sessionsMu.RLock()
dc := session.dataDC
s.sessionsMu.RUnlock()
if dc != nil && dc.ReadyState() == webrtc.DataChannelStateOpen {
ack, _ := json.Marshal(map[string]any{
"type": "ack",
"applied": true,
"timestamp": time.Now().UnixMilli(),
})
_ = dc.SendText(string(ack))
}
}
func (s *WebRTCService) broadcastTelemetry() {
s.sessionsMu.RLock()
defer s.sessionsMu.RUnlock()
for _, session := range s.sessions {
dc := session.dataDC
if dc != nil && dc.ReadyState() == webrtc.DataChannelStateOpen {
// Pull some stats/telemetry
car := s.engine.GetCarByDriver(session.driverID)
var speed float64
var x, y float64
var carID string
if car != nil {
speed = car.Speed
x = car.X
y = car.Y
carID = car.ID
}
telemetry := map[string]any{
"type": "telemetry",
"car_id": carID,
"driver_id": session.driverID,
"speed": speed,
"x": x,
"y": y,
"timestamp": time.Now().UnixMilli(),
}
payload, _ := json.Marshal(telemetry)
_ = dc.SendText(string(payload))
}
}
}
func (s *WebRTCService) CloseSession(driverID string) {
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
if session, ok := s.sessions[driverID]; ok {
s.logger.Info("closing WebRTC session for driver", "driver_id", driverID)
session.pc.Close()
session.cancelCtx()
delete(s.sessions, driverID)
}
}
func (s *WebRTCService) CloseAll() {
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
for driverID, session := range s.sessions {
s.logger.Info("closing WebRTC session (shutdown)", "driver_id", driverID)
session.pc.Close()
session.cancelCtx()
}
s.sessions = make(map[string]*driverSession)
}
+463
View File
@@ -1012,6 +1012,120 @@ const docTemplate = `{
}
}
},
"/api/leaderboard": {
"get": {
"description": "Returns paginated leaderboard list. Supports overall leaderboard or track-specific leaderboard for the current year.",
"produces": [
"application/json"
],
"tags": [
"leaderboard"
],
"summary": "Leaderboard",
"parameters": [
{
"type": "string",
"description": "Track ID. If empty, returns overall leaderboard.",
"name": "track_id",
"in": "query"
},
{
"type": "integer",
"description": "Year. Defaults to current year.",
"name": "year",
"in": "query"
},
{
"type": "integer",
"description": "Limit (1..200, default 50)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Offset (default 0)",
"name": "offset",
"in": "query"
},
{
"type": "string",
"description": "Current driver ID. Defaults to driver-seed-001 (mock Alice).",
"name": "X-Driver-Id",
"in": "header"
}
],
"responses": {
"200": {
"description": "Leaderboard page and current driver position",
"schema": {
"$ref": "#/definitions/transport.LeaderboardResponse"
}
},
"400": {
"description": "Invalid input",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/leaderboard/tracks": {
"get": {
"description": "Returns the list of scheduled tracks with their status (finished/active/planned), top 3 drivers, and current driver position, sorted by calendar start date.",
"produces": [
"application/json"
],
"tags": [
"leaderboard"
],
"summary": "Track Calendar Leaderboards",
"parameters": [
{
"type": "integer",
"description": "Year. Defaults to current year.",
"name": "year",
"in": "query"
},
{
"type": "string",
"description": "Current driver ID. Defaults to driver-seed-001 (mock Alice).",
"name": "X-Driver-Id",
"in": "header"
}
],
"responses": {
"200": {
"description": "Calendar leaderboards list",
"schema": {
"$ref": "#/definitions/transport.TrackCalendarLeaderboardResponse"
}
},
"400": {
"description": "Invalid input",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/races": {
"get": {
"description": "Returns one keyset page of races. Live races (status lobby|racing) come from in-memory lobby.Service; finished races come from Postgres. The two sources are merged and ordered by (started_ms DESC, id ASC). The cursor is opaque: pass back the next_cursor verbatim from a previous page.\n## Filters\n- ` + "`" + `status` + "`" + ` (comma-separated, e.g. \"racing,finished\"). Recognised values: all, lobby, racing, finished.\n- ` + "`" + `track_id` + "`" + ` (exact match against a physical track id; custom lobbies are not supported in this build).\n- ` + "`" + `limit` + "`" + ` (1..200, default 50)",
@@ -1520,6 +1634,123 @@ const docTemplate = `{
}
}
},
"/api/tracks/calendar": {
"get": {
"description": "Returns the full calendar of track active periods.",
"produces": [
"application/json"
],
"tags": [
"tracks"
],
"summary": "Get Track Calendar",
"responses": {
"200": {
"description": "List of scheduled active periods",
"schema": {
"$ref": "#/definitions/transport.TrackCalendarResponse"
}
},
"500": {
"description": "Internal error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"post": {
"description": "Schedules an active period for a physical track.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"tracks"
],
"summary": "Schedule Track Active Period",
"parameters": [
{
"description": "Calendar entry details",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/transport.TrackCalendarCreateRequest"
}
}
],
"responses": {
"201": {
"description": "Created calendar entry",
"schema": {
"$ref": "#/definitions/transport.TrackCalendarEntry"
}
},
"400": {
"description": "Invalid input",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Track not found",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/tracks/calendar/{id}": {
"delete": {
"description": "Deletes a scheduled active period by ID.",
"tags": [
"tracks"
],
"summary": "Delete Scheduled Period",
"parameters": [
{
"type": "integer",
"description": "Calendar Entry ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/tracks/{id}": {
"get": {
"description": "Dispatches on HTTP method. The id is taken from the URL path (` + "`" + `/api/tracks/{id}` + "`" + `). System tracks are immutable and cannot be updated or deleted.",
@@ -1846,6 +2077,40 @@ const docTemplate = `{
}
}
},
"/api/webrtc/connect": {
"post": {
"description": "Accepts SDP Offer, negotiates peer connection, and returns SDP Answer. Routes video and control data via DataChannels.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"webrtc"
],
"summary": "Establish WebRTC connection for driver-car 1-to-1 proxying",
"parameters": [
{
"description": "SDP Offer details",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/transport.WebRTCConnectRequest"
}
}
],
"responses": {
"200": {
"description": "SDP Answer negotiation success",
"schema": {
"$ref": "#/definitions/transport.WebRTCConnectResponse"
}
}
}
}
},
"/health": {
"get": {
"description": "Returns 200 OK with current engine phase, WS connection count and total snapshot drops. Intended for load-balancer health checks.",
@@ -2281,6 +2546,78 @@ const docTemplate = `{
}
}
},
"transport.LeaderboardEntry": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string",
"example": "https://cdn.example.com/u/ace.png"
},
"best_pos": {
"description": "Only populated for track-specific leaderboard",
"type": "integer",
"example": 1
},
"best_time_ms": {
"type": "integer",
"example": 15000
},
"clan_id": {
"type": "string",
"example": "clan-seed-001"
},
"clan_tag": {
"type": "string",
"example": "ACE"
},
"driver_id": {
"type": "string",
"example": "driver-seed-001"
},
"name": {
"type": "string",
"example": "Alice"
},
"nickname": {
"type": "string",
"example": "ACE"
},
"points": {
"type": "integer",
"example": 25
},
"rank": {
"type": "integer",
"example": 1
}
}
},
"transport.LeaderboardResponse": {
"type": "object",
"properties": {
"current_driver": {
"$ref": "#/definitions/transport.LeaderboardEntry"
},
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/transport.LeaderboardEntry"
}
},
"limit": {
"type": "integer",
"example": 50
},
"offset": {
"type": "integer",
"example": 0
},
"total": {
"type": "integer",
"example": 8
}
}
},
"transport.LobbyRace": {
"type": "object",
"properties": {
@@ -2741,6 +3078,100 @@ const docTemplate = `{
}
}
},
"transport.TrackCalendarCreateRequest": {
"type": "object",
"properties": {
"end_at": {
"type": "string",
"example": "2026-07-30T00:00:00Z"
},
"start_at": {
"type": "string",
"example": "2026-07-15T00:00:00Z"
},
"track_id": {
"type": "string",
"example": "monaco"
}
}
},
"transport.TrackCalendarEntry": {
"type": "object",
"properties": {
"end_at": {
"type": "string",
"example": "2026-07-30T00:00:00Z"
},
"id": {
"type": "integer",
"example": 1
},
"start_at": {
"type": "string",
"example": "2026-07-15T00:00:00Z"
},
"track_id": {
"type": "string",
"example": "monaco"
}
}
},
"transport.TrackCalendarLeaderboard": {
"type": "object",
"properties": {
"current_driver": {
"$ref": "#/definitions/transport.LeaderboardEntry"
},
"end_at": {
"type": "string",
"example": "2026-07-30T00:00:00Z"
},
"podium": {
"type": "array",
"items": {
"$ref": "#/definitions/transport.LeaderboardEntry"
}
},
"start_at": {
"type": "string",
"example": "2026-07-15T00:00:00Z"
},
"status": {
"type": "string",
"example": "active"
},
"track_id": {
"type": "string",
"example": "monaco"
},
"track_name": {
"type": "string",
"example": "Monaco"
}
}
},
"transport.TrackCalendarLeaderboardResponse": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/transport.TrackCalendarLeaderboard"
}
}
}
},
"transport.TrackCalendarResponse": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/transport.TrackCalendarEntry"
}
}
}
},
"transport.TrackCreateRequest": {
"type": "object",
"properties": {
@@ -2851,6 +3282,10 @@ const docTemplate = `{
"id": {
"type": "string"
},
"is_active": {
"type": "boolean",
"example": true
},
"lane_width_m": {
"type": "number"
},
@@ -2891,6 +3326,34 @@ const docTemplate = `{
"example": "0.1.0-poc"
}
}
},
"transport.WebRTCConnectRequest": {
"type": "object",
"properties": {
"driver_id": {
"type": "string",
"example": "driver-alice"
},
"sdp": {
"type": "string"
},
"type": {
"type": "string",
"example": "offer"
}
}
},
"transport.WebRTCConnectResponse": {
"type": "object",
"properties": {
"sdp": {
"type": "string"
},
"type": {
"type": "string",
"example": "answer"
}
}
}
}
}`
+463
View File
@@ -1010,6 +1010,120 @@
}
}
},
"/api/leaderboard": {
"get": {
"description": "Returns paginated leaderboard list. Supports overall leaderboard or track-specific leaderboard for the current year.",
"produces": [
"application/json"
],
"tags": [
"leaderboard"
],
"summary": "Leaderboard",
"parameters": [
{
"type": "string",
"description": "Track ID. If empty, returns overall leaderboard.",
"name": "track_id",
"in": "query"
},
{
"type": "integer",
"description": "Year. Defaults to current year.",
"name": "year",
"in": "query"
},
{
"type": "integer",
"description": "Limit (1..200, default 50)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Offset (default 0)",
"name": "offset",
"in": "query"
},
{
"type": "string",
"description": "Current driver ID. Defaults to driver-seed-001 (mock Alice).",
"name": "X-Driver-Id",
"in": "header"
}
],
"responses": {
"200": {
"description": "Leaderboard page and current driver position",
"schema": {
"$ref": "#/definitions/transport.LeaderboardResponse"
}
},
"400": {
"description": "Invalid input",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/leaderboard/tracks": {
"get": {
"description": "Returns the list of scheduled tracks with their status (finished/active/planned), top 3 drivers, and current driver position, sorted by calendar start date.",
"produces": [
"application/json"
],
"tags": [
"leaderboard"
],
"summary": "Track Calendar Leaderboards",
"parameters": [
{
"type": "integer",
"description": "Year. Defaults to current year.",
"name": "year",
"in": "query"
},
{
"type": "string",
"description": "Current driver ID. Defaults to driver-seed-001 (mock Alice).",
"name": "X-Driver-Id",
"in": "header"
}
],
"responses": {
"200": {
"description": "Calendar leaderboards list",
"schema": {
"$ref": "#/definitions/transport.TrackCalendarLeaderboardResponse"
}
},
"400": {
"description": "Invalid input",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/races": {
"get": {
"description": "Returns one keyset page of races. Live races (status lobby|racing) come from in-memory lobby.Service; finished races come from Postgres. The two sources are merged and ordered by (started_ms DESC, id ASC). The cursor is opaque: pass back the next_cursor verbatim from a previous page.\n## Filters\n- `status` (comma-separated, e.g. \"racing,finished\"). Recognised values: all, lobby, racing, finished.\n- `track_id` (exact match against a physical track id; custom lobbies are not supported in this build).\n- `limit` (1..200, default 50)",
@@ -1518,6 +1632,123 @@
}
}
},
"/api/tracks/calendar": {
"get": {
"description": "Returns the full calendar of track active periods.",
"produces": [
"application/json"
],
"tags": [
"tracks"
],
"summary": "Get Track Calendar",
"responses": {
"200": {
"description": "List of scheduled active periods",
"schema": {
"$ref": "#/definitions/transport.TrackCalendarResponse"
}
},
"500": {
"description": "Internal error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"post": {
"description": "Schedules an active period for a physical track.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"tracks"
],
"summary": "Schedule Track Active Period",
"parameters": [
{
"description": "Calendar entry details",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/transport.TrackCalendarCreateRequest"
}
}
],
"responses": {
"201": {
"description": "Created calendar entry",
"schema": {
"$ref": "#/definitions/transport.TrackCalendarEntry"
}
},
"400": {
"description": "Invalid input",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Track not found",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/tracks/calendar/{id}": {
"delete": {
"description": "Deletes a scheduled active period by ID.",
"tags": [
"tracks"
],
"summary": "Delete Scheduled Period",
"parameters": [
{
"type": "integer",
"description": "Calendar Entry ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/tracks/{id}": {
"get": {
"description": "Dispatches on HTTP method. The id is taken from the URL path (`/api/tracks/{id}`). System tracks are immutable and cannot be updated or deleted.",
@@ -1844,6 +2075,40 @@
}
}
},
"/api/webrtc/connect": {
"post": {
"description": "Accepts SDP Offer, negotiates peer connection, and returns SDP Answer. Routes video and control data via DataChannels.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"webrtc"
],
"summary": "Establish WebRTC connection for driver-car 1-to-1 proxying",
"parameters": [
{
"description": "SDP Offer details",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/transport.WebRTCConnectRequest"
}
}
],
"responses": {
"200": {
"description": "SDP Answer negotiation success",
"schema": {
"$ref": "#/definitions/transport.WebRTCConnectResponse"
}
}
}
}
},
"/health": {
"get": {
"description": "Returns 200 OK with current engine phase, WS connection count and total snapshot drops. Intended for load-balancer health checks.",
@@ -2279,6 +2544,78 @@
}
}
},
"transport.LeaderboardEntry": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string",
"example": "https://cdn.example.com/u/ace.png"
},
"best_pos": {
"description": "Only populated for track-specific leaderboard",
"type": "integer",
"example": 1
},
"best_time_ms": {
"type": "integer",
"example": 15000
},
"clan_id": {
"type": "string",
"example": "clan-seed-001"
},
"clan_tag": {
"type": "string",
"example": "ACE"
},
"driver_id": {
"type": "string",
"example": "driver-seed-001"
},
"name": {
"type": "string",
"example": "Alice"
},
"nickname": {
"type": "string",
"example": "ACE"
},
"points": {
"type": "integer",
"example": 25
},
"rank": {
"type": "integer",
"example": 1
}
}
},
"transport.LeaderboardResponse": {
"type": "object",
"properties": {
"current_driver": {
"$ref": "#/definitions/transport.LeaderboardEntry"
},
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/transport.LeaderboardEntry"
}
},
"limit": {
"type": "integer",
"example": 50
},
"offset": {
"type": "integer",
"example": 0
},
"total": {
"type": "integer",
"example": 8
}
}
},
"transport.LobbyRace": {
"type": "object",
"properties": {
@@ -2739,6 +3076,100 @@
}
}
},
"transport.TrackCalendarCreateRequest": {
"type": "object",
"properties": {
"end_at": {
"type": "string",
"example": "2026-07-30T00:00:00Z"
},
"start_at": {
"type": "string",
"example": "2026-07-15T00:00:00Z"
},
"track_id": {
"type": "string",
"example": "monaco"
}
}
},
"transport.TrackCalendarEntry": {
"type": "object",
"properties": {
"end_at": {
"type": "string",
"example": "2026-07-30T00:00:00Z"
},
"id": {
"type": "integer",
"example": 1
},
"start_at": {
"type": "string",
"example": "2026-07-15T00:00:00Z"
},
"track_id": {
"type": "string",
"example": "monaco"
}
}
},
"transport.TrackCalendarLeaderboard": {
"type": "object",
"properties": {
"current_driver": {
"$ref": "#/definitions/transport.LeaderboardEntry"
},
"end_at": {
"type": "string",
"example": "2026-07-30T00:00:00Z"
},
"podium": {
"type": "array",
"items": {
"$ref": "#/definitions/transport.LeaderboardEntry"
}
},
"start_at": {
"type": "string",
"example": "2026-07-15T00:00:00Z"
},
"status": {
"type": "string",
"example": "active"
},
"track_id": {
"type": "string",
"example": "monaco"
},
"track_name": {
"type": "string",
"example": "Monaco"
}
}
},
"transport.TrackCalendarLeaderboardResponse": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/transport.TrackCalendarLeaderboard"
}
}
}
},
"transport.TrackCalendarResponse": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/transport.TrackCalendarEntry"
}
}
}
},
"transport.TrackCreateRequest": {
"type": "object",
"properties": {
@@ -2849,6 +3280,10 @@
"id": {
"type": "string"
},
"is_active": {
"type": "boolean",
"example": true
},
"lane_width_m": {
"type": "number"
},
@@ -2889,6 +3324,34 @@
"example": "0.1.0-poc"
}
}
},
"transport.WebRTCConnectRequest": {
"type": "object",
"properties": {
"driver_id": {
"type": "string",
"example": "driver-alice"
},
"sdp": {
"type": "string"
},
"type": {
"type": "string",
"example": "offer"
}
}
},
"transport.WebRTCConnectResponse": {
"type": "object",
"properties": {
"sdp": {
"type": "string"
},
"type": {
"type": "string",
"example": "answer"
}
}
}
}
}
+319
View File
@@ -252,6 +252,58 @@ definitions:
type:
$ref: '#/definitions/transport.MessageType'
type: object
transport.LeaderboardEntry:
properties:
avatar_url:
example: https://cdn.example.com/u/ace.png
type: string
best_pos:
description: Only populated for track-specific leaderboard
example: 1
type: integer
best_time_ms:
example: 15000
type: integer
clan_id:
example: clan-seed-001
type: string
clan_tag:
example: ACE
type: string
driver_id:
example: driver-seed-001
type: string
name:
example: Alice
type: string
nickname:
example: ACE
type: string
points:
example: 25
type: integer
rank:
example: 1
type: integer
type: object
transport.LeaderboardResponse:
properties:
current_driver:
$ref: '#/definitions/transport.LeaderboardEntry'
items:
items:
$ref: '#/definitions/transport.LeaderboardEntry'
type: array
limit:
example: 50
type: integer
offset:
example: 0
type: integer
total:
example: 8
type: integer
type: object
transport.LobbyRace:
properties:
created_ms:
@@ -592,6 +644,71 @@ definitions:
width_m:
type: number
type: object
transport.TrackCalendarCreateRequest:
properties:
end_at:
example: "2026-07-30T00:00:00Z"
type: string
start_at:
example: "2026-07-15T00:00:00Z"
type: string
track_id:
example: monaco
type: string
type: object
transport.TrackCalendarEntry:
properties:
end_at:
example: "2026-07-30T00:00:00Z"
type: string
id:
example: 1
type: integer
start_at:
example: "2026-07-15T00:00:00Z"
type: string
track_id:
example: monaco
type: string
type: object
transport.TrackCalendarLeaderboard:
properties:
current_driver:
$ref: '#/definitions/transport.LeaderboardEntry'
end_at:
example: "2026-07-30T00:00:00Z"
type: string
podium:
items:
$ref: '#/definitions/transport.LeaderboardEntry'
type: array
start_at:
example: "2026-07-15T00:00:00Z"
type: string
status:
example: active
type: string
track_id:
example: monaco
type: string
track_name:
example: Monaco
type: string
type: object
transport.TrackCalendarLeaderboardResponse:
properties:
items:
items:
$ref: '#/definitions/transport.TrackCalendarLeaderboard'
type: array
type: object
transport.TrackCalendarResponse:
properties:
items:
items:
$ref: '#/definitions/transport.TrackCalendarEntry'
type: array
type: object
transport.TrackCreateRequest:
properties:
track:
@@ -664,6 +781,9 @@ definitions:
type: string
id:
type: string
is_active:
example: true
type: boolean
lane_width_m:
type: number
length_m:
@@ -691,6 +811,25 @@ definitions:
example: 0.1.0-poc
type: string
type: object
transport.WebRTCConnectRequest:
properties:
driver_id:
example: driver-alice
type: string
sdp:
type: string
type:
example: offer
type: string
type: object
transport.WebRTCConnectResponse:
properties:
sdp:
type: string
type:
example: answer
type: string
type: object
host: localhost:8080
info:
contact:
@@ -1389,6 +1528,84 @@ paths:
summary: Get / update / delete a driver by id
tags:
- drivers
/api/leaderboard:
get:
description: Returns paginated leaderboard list. Supports overall leaderboard
or track-specific leaderboard for the current year.
parameters:
- description: Track ID. If empty, returns overall leaderboard.
in: query
name: track_id
type: string
- description: Year. Defaults to current year.
in: query
name: year
type: integer
- description: Limit (1..200, default 50)
in: query
name: limit
type: integer
- description: Offset (default 0)
in: query
name: offset
type: integer
- description: Current driver ID. Defaults to driver-seed-001 (mock Alice).
in: header
name: X-Driver-Id
type: string
produces:
- application/json
responses:
"200":
description: Leaderboard page and current driver position
schema:
$ref: '#/definitions/transport.LeaderboardResponse'
"400":
description: Invalid input
schema:
additionalProperties: true
type: object
"500":
description: Internal server error
schema:
additionalProperties: true
type: object
summary: Leaderboard
tags:
- leaderboard
/api/leaderboard/tracks:
get:
description: Returns the list of scheduled tracks with their status (finished/active/planned),
top 3 drivers, and current driver position, sorted by calendar start date.
parameters:
- description: Year. Defaults to current year.
in: query
name: year
type: integer
- description: Current driver ID. Defaults to driver-seed-001 (mock Alice).
in: header
name: X-Driver-Id
type: string
produces:
- application/json
responses:
"200":
description: Calendar leaderboards list
schema:
$ref: '#/definitions/transport.TrackCalendarLeaderboardResponse'
"400":
description: Invalid input
schema:
additionalProperties: true
type: object
"500":
description: Internal server error
schema:
additionalProperties: true
type: object
summary: Track Calendar Leaderboards
tags:
- leaderboard
/api/races:
get:
description: |-
@@ -1908,6 +2125,85 @@ paths:
summary: Get / update / delete a track by id
tags:
- tracks
/api/tracks/calendar:
get:
description: Returns the full calendar of track active periods.
produces:
- application/json
responses:
"200":
description: List of scheduled active periods
schema:
$ref: '#/definitions/transport.TrackCalendarResponse'
"500":
description: Internal error
schema:
additionalProperties: true
type: object
summary: Get Track Calendar
tags:
- tracks
post:
consumes:
- application/json
description: Schedules an active period for a physical track.
parameters:
- description: Calendar entry details
in: body
name: payload
required: true
schema:
$ref: '#/definitions/transport.TrackCalendarCreateRequest'
produces:
- application/json
responses:
"201":
description: Created calendar entry
schema:
$ref: '#/definitions/transport.TrackCalendarEntry'
"400":
description: Invalid input
schema:
additionalProperties: true
type: object
"404":
description: Track not found
schema:
additionalProperties: true
type: object
"500":
description: Internal error
schema:
additionalProperties: true
type: object
summary: Schedule Track Active Period
tags:
- tracks
/api/tracks/calendar/{id}:
delete:
description: Deletes a scheduled active period by ID.
parameters:
- description: Calendar Entry ID
in: path
name: id
required: true
type: integer
responses:
"204":
description: No Content
"404":
description: Not found
schema:
additionalProperties: true
type: object
"500":
description: Internal error
schema:
additionalProperties: true
type: object
summary: Delete Scheduled Period
tags:
- tracks
/api/version:
get:
description: Returns the current server version.
@@ -1973,6 +2269,29 @@ paths:
summary: Get MJPEG video stream from ESP32
tags:
- video
/api/webrtc/connect:
post:
consumes:
- application/json
description: Accepts SDP Offer, negotiates peer connection, and returns SDP
Answer. Routes video and control data via DataChannels.
parameters:
- description: SDP Offer details
in: body
name: body
required: true
schema:
$ref: '#/definitions/transport.WebRTCConnectRequest'
produces:
- application/json
responses:
"200":
description: SDP Answer negotiation success
schema:
$ref: '#/definitions/transport.WebRTCConnectResponse'
summary: Establish WebRTC connection for driver-car 1-to-1 proxying
tags:
- webrtc
/health:
get:
description: Returns 200 OK with current engine phase, WS connection count and
+24 -4
View File
@@ -20,12 +20,32 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/pion/datachannel v1.6.2 // indirect
github.com/pion/dtls/v3 v3.1.4 // indirect
github.com/pion/ice/v4 v4.2.7 // indirect
github.com/pion/interceptor v0.1.45 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/rtp v1.10.2 // indirect
github.com/pion/sctp v1.10.3 // indirect
github.com/pion/sdp/v3 v3.0.19 // indirect
github.com/pion/srtp/v3 v3.0.12 // indirect
github.com/pion/stun/v3 v3.1.6 // indirect
github.com/pion/transport/v4 v4.0.2 // indirect
github.com/pion/turn/v5 v5.0.10 // indirect
github.com/pion/webrtc/v4 v4.2.16 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/swaggo/http-swagger v1.3.4 // indirect
github.com/swaggo/swag v1.16.4 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/tools v0.36.0 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.41.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
+48
View File
@@ -39,6 +39,38 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc=
github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E=
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao=
github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY=
github.com/pion/interceptor v0.1.45 h1:6PUo/5829bIfRFIPPJQzuDn8EjxRTSB/CSD7QVCOaqo=
github.com/pion/interceptor v0.1.45/go.mod h1:gNDYM/uFKcLe/B3gS2/7+aw6z+RDiMy2qKTnF1LO31w=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
github.com/pion/sctp v1.10.3 h1:1gBtLMA9lmwNuJkZSZJCdD5/Hz4yJs+7dAqi6ZY97QI=
github.com/pion/sctp v1.10.3/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0=
github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ=
github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU=
github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc=
github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/turn/v5 v5.0.10 h1:mOMZjudflXpte5OsCnXztpUKwNXcpXIAzMBnq9TXOSQ=
github.com/pion/turn/v5 v5.0.10/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E=
github.com/pion/webrtc/v4 v4.2.16 h1:oK1GAg0TWJtZWYB8J/BgTgGWPoV2148gQWocH12vr3Q=
github.com/pion/webrtc/v4 v4.2.16/go.mod h1:y4HjLAkX90LH+C/qPqGOUgz8RA8CbDj3Iar3d+2hdKQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -53,9 +85,13 @@ github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -63,16 +99,22 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -82,11 +124,17 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+41
View File
@@ -607,3 +607,44 @@ func (s *Service) ListCarsByOwner(ownerID string) []CarMeta {
}
return out
}
// GetTrackCalendar returns all track calendar entries from the store.
func (s *Service) GetTrackCalendar(ctx context.Context) ([]TrackCalendarEntry, error) {
if s.store == nil {
return nil, ErrStoreMissing
}
return s.store.GetTrackCalendar(ctx)
}
// CreateTrackCalendar creates a new calendar entry.
func (s *Service) CreateTrackCalendar(ctx context.Context, trackID string, startAt, endAt time.Time) (TrackCalendarEntry, error) {
if s.store == nil {
return TrackCalendarEntry{}, ErrStoreMissing
}
s.mu.RLock()
_, trackExists := s.tracks[TrackID(trackID)]
s.mu.RUnlock()
if !trackExists {
return TrackCalendarEntry{}, fmt.Errorf("%w: track %s does not exist", ErrNotFound, trackID)
}
if startAt.After(endAt) || startAt.Equal(endAt) {
return TrackCalendarEntry{}, fmt.Errorf("%w: start time must be before end time", ErrInvalidInput)
}
entry := TrackCalendarEntry{
TrackID: trackID,
StartAt: startAt,
EndAt: endAt,
}
return s.store.CreateTrackCalendar(ctx, entry)
}
// DeleteTrackCalendar deletes a calendar entry.
func (s *Service) DeleteTrackCalendar(ctx context.Context, id int) error {
if s.store == nil {
return ErrStoreMissing
}
return s.store.DeleteTrackCalendar(ctx, id)
}
+50
View File
@@ -399,3 +399,53 @@ func TestServiceWithoutStore(t *testing.T) {
t.Fatal("expected error when store is nil")
}
}
func TestTrackCalendar(t *testing.T) {
s, _ := loadSeeds()
defer s.Stop()
ctx := context.Background()
// 1. Initially calendar should be empty
entries, err := s.GetTrackCalendar(ctx)
if err != nil {
t.Fatalf("get calendar: %v", err)
}
if len(entries) != 0 {
t.Errorf("expected 0 entries, got %d", len(entries))
}
// 2. Schedule a track
start := time.Now().Add(-1 * time.Hour)
end := time.Now().Add(1 * time.Hour)
created, err := s.CreateTrackCalendar(ctx, "monaco", start, end)
if err != nil {
t.Fatalf("create calendar: %v", err)
}
if created.TrackID != "monaco" || !created.StartAt.Equal(start) || !created.EndAt.Equal(end) {
t.Errorf("invalid created entry: %+v", created)
}
// 3. Get calendar should return the entry
entries, err = s.GetTrackCalendar(ctx)
if err != nil {
t.Fatalf("get calendar: %v", err)
}
if len(entries) != 1 || entries[0].ID != created.ID {
t.Errorf("expected 1 entry with id %d, got %v", created.ID, entries)
}
// 4. Delete entry
err = s.DeleteTrackCalendar(ctx, created.ID)
if err != nil {
t.Fatalf("delete calendar: %v", err)
}
// 5. Get calendar should be empty again
entries, err = s.GetTrackCalendar(ctx)
if err != nil {
t.Fatalf("get calendar: %v", err)
}
if len(entries) != 0 {
t.Errorf("expected 0 entries, got %d", len(entries))
}
}
+5
View File
@@ -27,4 +27,9 @@ type Store interface {
// UpdateCarStats applies a partial mutation of read-mostly stats
// fields on a car. Used by the race engine hooks.
UpdateCarStats(ctx context.Context, id string, fn func(*CarMeta)) error
// Track Calendar methods
GetTrackCalendar(ctx context.Context) ([]TrackCalendarEntry, error)
CreateTrackCalendar(ctx context.Context, entry TrackCalendarEntry) (TrackCalendarEntry, error)
DeleteTrackCalendar(ctx context.Context, id int) error
}
+31
View File
@@ -11,12 +11,15 @@ type memoryStore struct {
mu sync.Mutex
tracks map[TrackID]TrackMeta
cars map[CarID]CarMeta
calendar []TrackCalendarEntry
nextID int
}
func newMemoryStore() *memoryStore {
return &memoryStore{
tracks: make(map[TrackID]TrackMeta),
cars: make(map[CarID]CarMeta),
nextID: 1,
}
}
@@ -73,3 +76,31 @@ func (m *memoryStore) UpdateCarStats(_ context.Context, id string, fn func(*CarM
m.cars[id] = c
return nil
}
func (m *memoryStore) GetTrackCalendar(_ context.Context) ([]TrackCalendarEntry, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := append([]TrackCalendarEntry(nil), m.calendar...)
return out, nil
}
func (m *memoryStore) CreateTrackCalendar(_ context.Context, entry TrackCalendarEntry) (TrackCalendarEntry, error) {
m.mu.Lock()
defer m.mu.Unlock()
entry.ID = m.nextID
m.nextID++
m.calendar = append(m.calendar, entry)
return entry, nil
}
func (m *memoryStore) DeleteTrackCalendar(_ context.Context, id int) error {
m.mu.Lock()
defer m.mu.Unlock()
for i, entry := range m.calendar {
if entry.ID == id {
m.calendar = append(m.calendar[:i], m.calendar[i+1:]...)
return nil
}
}
return ErrNotFound
}
+8
View File
@@ -94,6 +94,14 @@ type TrackMeta struct {
UpdatedMs int64 `json:"updated_ms"`
}
// TrackCalendarEntry describes one active period for a track.
type TrackCalendarEntry struct {
ID int `json:"id"`
TrackID string `json:"track_id"`
StartAt time.Time `json:"start_at"`
EndAt time.Time `json:"end_at"`
}
// MotorSpec describes the electric motor.
type MotorSpec struct {
Kind string `json:"kind"` // brushed | brushless
+3 -1
View File
@@ -47,6 +47,7 @@ type Car struct {
ID string
DriverID string // session id
DriverName string
DeviceID *int // internal mapping to physical ESP32 device ID
X, Y float64
Heading float64 // radians
Speed float64 // m/s
@@ -169,7 +170,7 @@ 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) {
func (e *Engine) AddCar(driverID, driverName string, slot int, deviceID *int) (*Car, error) {
e.mu.Lock()
defer e.mu.Unlock()
@@ -185,6 +186,7 @@ func (e *Engine) AddCar(driverID, driverName string, slot int) (*Car, error) {
ID: id,
DriverID: driverID,
DriverName: driverName,
DeviceID: deviceID,
// Spread cars along start grid (x = 0, y = slot * 0.4 m).
Y: float64(slot) * 0.4,
}
+1 -1
View File
@@ -65,7 +65,7 @@ type DriverMeta struct {
LastSeenMs int64 `json:"last_seen_ms"`
Country string `json:"country,omitempty"` // optional, for UI
AvatarHue int `json:"avatar_hue,omitempty"` // 0..360, deterministic per id
DeviceID *int `json:"device_id,omitempty"`
DeviceID *int `json:"-"`
}
// Snapshot is the immutable view broadcast to clients.
+497
View File
@@ -0,0 +1,497 @@
package races
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// LeaderboardEntry is a single row in the leaderboard.
type LeaderboardEntry struct {
DriverID string
Name string
Nickname string
AvatarURL string
ClanID string
ClanTag string
Points int
BestPos *int // deprecated/optional
BestTimeMs *int64
Rank int
}
// LeaderboardResult groups the paginated items, total count, and current driver.
type LeaderboardResult struct {
Items []LeaderboardEntry
Total int
CurrentDriver *LeaderboardEntry
}
func getYearTimestamps(year int) (int64, int64) {
start := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(year+1, time.January, 1, 0, 0, 0, 0, time.UTC)
return start.UnixMilli(), end.UnixMilli()
}
// GetTrackLeaderboard returns track-specific leaderboard entries for the given track and year.
func (s *PgStore) GetTrackLeaderboard(ctx context.Context, trackID string, year int, limit, offset int) ([]LeaderboardEntry, int, error) {
startMs, endMs := getYearTimestamps(year)
if limit <= 0 {
limit = 50
}
countQuery := `
WITH best_results AS (
SELECT
rd.driver_id
FROM race_drivers rd
JOIN races r ON rd.race_id = r.id
WHERE r.status = 'finished'
AND r.track_id = $1
AND r.finished_ms >= $2
AND r.finished_ms < $3
GROUP BY rd.driver_id
)
SELECT COUNT(*) FROM best_results`
var total int
err := s.pool.QueryRow(ctx, countQuery, trackID, startMs, endMs).Scan(&total)
if err != nil {
return nil, 0, fmt.Errorf("count track leaderboard: %w", err)
}
if total == 0 {
return nil, 0, nil
}
query := `
WITH best_results AS (
SELECT
r.track_id,
rd.driver_id,
MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms ELSE NULL END) as best_time
FROM race_drivers rd
JOIN races r ON rd.race_id = r.id
WHERE r.status = 'finished'
AND r.finished_ms >= $1
AND r.finished_ms < $2
GROUP BY r.track_id, rd.driver_id
),
ranked_times AS (
SELECT
track_id,
driver_id,
best_time,
ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY best_time ASC NULLS LAST, driver_id ASC) as time_rank
FROM best_results
),
track_points AS (
SELECT
track_id,
driver_id,
best_time,
CASE
WHEN best_time IS NULL THEN 1
WHEN time_rank <= 3 THEN 25
WHEN time_rank <= 10 THEN 18
WHEN time_rank <= 20 THEN 15
WHEN time_rank <= 30 THEN 12
WHEN time_rank <= 40 THEN 10
WHEN time_rank <= 50 THEN 8
WHEN time_rank <= 60 THEN 6
WHEN time_rank <= 70 THEN 4
WHEN time_rank <= 80 THEN 2
WHEN time_rank <= 90 THEN 1
ELSE 1
END as points
FROM ranked_times
),
ranked_leaderboard AS (
SELECT
tp.driver_id,
d.name,
d.nickname,
d.avatar_url,
COALESCE(d.clan_id, '') as clan_id,
COALESCE(c.tag, '') as clan_tag,
tp.points,
tp.best_time,
ROW_NUMBER() OVER (ORDER BY tp.points DESC, tp.best_time ASC NULLS LAST, tp.driver_id ASC) as rank
FROM track_points tp
JOIN drivers d ON tp.driver_id = d.id
LEFT JOIN clans c ON d.clan_id = c.id
WHERE tp.track_id = $3
)
SELECT rank, driver_id, name, nickname, avatar_url, clan_id, clan_tag, points, best_time
FROM ranked_leaderboard
ORDER BY rank ASC
LIMIT $4 OFFSET $5`
rows, err := s.pool.Query(ctx, query, startMs, endMs, trackID, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("query track leaderboard: %w", err)
}
defer rows.Close()
var entries []LeaderboardEntry
for rows.Next() {
var entry LeaderboardEntry
if err := rows.Scan(
&entry.Rank, &entry.DriverID, &entry.Name, &entry.Nickname,
&entry.AvatarURL, &entry.ClanID, &entry.ClanTag, &entry.Points, &entry.BestTimeMs,
); err != nil {
return nil, 0, err
}
entries = append(entries, entry)
}
return entries, total, nil
}
// GetTrackLeaderboardDriver returns a single driver's rank on a track.
func (s *PgStore) GetTrackLeaderboardDriver(ctx context.Context, trackID string, year int, driverID string) (*LeaderboardEntry, error) {
startMs, endMs := getYearTimestamps(year)
query := `
WITH best_results AS (
SELECT
r.track_id,
rd.driver_id,
MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms ELSE NULL END) as best_time
FROM race_drivers rd
JOIN races r ON rd.race_id = r.id
WHERE r.status = 'finished'
AND r.finished_ms >= $1
AND r.finished_ms < $2
GROUP BY r.track_id, rd.driver_id
),
ranked_times AS (
SELECT
track_id,
driver_id,
best_time,
ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY best_time ASC NULLS LAST, driver_id ASC) as time_rank
FROM best_results
),
track_points AS (
SELECT
track_id,
driver_id,
best_time,
CASE
WHEN best_time IS NULL THEN 1
WHEN time_rank <= 3 THEN 25
WHEN time_rank <= 10 THEN 18
WHEN time_rank <= 20 THEN 15
WHEN time_rank <= 30 THEN 12
WHEN time_rank <= 40 THEN 10
WHEN time_rank <= 50 THEN 8
WHEN time_rank <= 60 THEN 6
WHEN time_rank <= 70 THEN 4
WHEN time_rank <= 80 THEN 2
WHEN time_rank <= 90 THEN 1
ELSE 1
END as points
FROM ranked_times
),
ranked_leaderboard AS (
SELECT
tp.driver_id,
d.name,
d.nickname,
d.avatar_url,
COALESCE(d.clan_id, '') as clan_id,
COALESCE(c.tag, '') as clan_tag,
tp.points,
tp.best_time,
ROW_NUMBER() OVER (ORDER BY tp.points DESC, tp.best_time ASC NULLS LAST, tp.driver_id ASC) as rank
FROM track_points tp
JOIN drivers d ON tp.driver_id = d.id
LEFT JOIN clans c ON d.clan_id = c.id
WHERE tp.track_id = $3
)
SELECT rank, driver_id, name, nickname, avatar_url, clan_id, clan_tag, points, best_time
FROM ranked_leaderboard
WHERE driver_id = $4`
row := s.pool.QueryRow(ctx, query, startMs, endMs, trackID, driverID)
var entry LeaderboardEntry
if err := row.Scan(
&entry.Rank, &entry.DriverID, &entry.Name, &entry.Nickname,
&entry.AvatarURL, &entry.ClanID, &entry.ClanTag, &entry.Points, &entry.BestTimeMs,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &entry, nil
}
// GetOverallLeaderboard returns overall leaderboard entries for the given year.
func (s *PgStore) GetOverallLeaderboard(ctx context.Context, year int, limit, offset int) ([]LeaderboardEntry, int, error) {
startMs, endMs := getYearTimestamps(year)
if limit <= 0 {
limit = 50
}
countQuery := `
WITH best_results AS (
SELECT
rd.driver_id
FROM race_drivers rd
JOIN races r ON rd.race_id = r.id
WHERE r.status = 'finished'
AND r.finished_ms >= $1
AND r.finished_ms < $2
GROUP BY rd.driver_id
)
SELECT COUNT(*) FROM best_results`
var total int
err := s.pool.QueryRow(ctx, countQuery, startMs, endMs).Scan(&total)
if err != nil {
return nil, 0, fmt.Errorf("count overall leaderboard: %w", err)
}
if total == 0 {
return nil, 0, nil
}
query := `
WITH best_results AS (
SELECT
r.track_id,
rd.driver_id,
MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms ELSE NULL END) as best_time
FROM race_drivers rd
JOIN races r ON rd.race_id = r.id
WHERE r.status = 'finished'
AND r.finished_ms >= $1
AND r.finished_ms < $2
GROUP BY r.track_id, rd.driver_id
),
ranked_times AS (
SELECT
track_id,
driver_id,
best_time,
ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY best_time ASC NULLS LAST, driver_id ASC) as time_rank
FROM best_results
),
track_points AS (
SELECT
track_id,
driver_id,
best_time,
CASE
WHEN best_time IS NULL THEN 1
WHEN time_rank <= 3 THEN 25
WHEN time_rank <= 10 THEN 18
WHEN time_rank <= 20 THEN 15
WHEN time_rank <= 30 THEN 12
WHEN time_rank <= 40 THEN 10
WHEN time_rank <= 50 THEN 8
WHEN time_rank <= 60 THEN 6
WHEN time_rank <= 70 THEN 4
WHEN time_rank <= 80 THEN 2
WHEN time_rank <= 90 THEN 1
ELSE 1
END as points
FROM ranked_times
),
overall_points AS (
SELECT
driver_id,
SUM(points) as points
FROM track_points
GROUP BY driver_id
),
ranked_overall AS (
SELECT
op.driver_id,
d.name,
d.nickname,
d.avatar_url,
COALESCE(d.clan_id, '') as clan_id,
COALESCE(c.tag, '') as clan_tag,
op.points,
ROW_NUMBER() OVER (ORDER BY op.points DESC, op.driver_id ASC) as rank
FROM overall_points op
JOIN drivers d ON op.driver_id = d.id
LEFT JOIN clans c ON d.clan_id = c.id
)
SELECT rank, driver_id, name, nickname, avatar_url, clan_id, clan_tag, points
FROM ranked_overall
ORDER BY rank ASC
LIMIT $3 OFFSET $4`
rows, err := s.pool.Query(ctx, query, startMs, endMs, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("query overall leaderboard: %w", err)
}
defer rows.Close()
var entries []LeaderboardEntry
for rows.Next() {
var entry LeaderboardEntry
if err := rows.Scan(
&entry.Rank, &entry.DriverID, &entry.Name, &entry.Nickname,
&entry.AvatarURL, &entry.ClanID, &entry.ClanTag, &entry.Points,
); err != nil {
return nil, 0, err
}
entries = append(entries, entry)
}
return entries, total, nil
}
// GetOverallLeaderboardDriver returns a single driver's overall rank.
func (s *PgStore) GetOverallLeaderboardDriver(ctx context.Context, year int, driverID string) (*LeaderboardEntry, error) {
startMs, endMs := getYearTimestamps(year)
query := `
WITH best_results AS (
SELECT
r.track_id,
rd.driver_id,
MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms ELSE NULL END) as best_time
FROM race_drivers rd
JOIN races r ON rd.race_id = r.id
WHERE r.status = 'finished'
AND r.finished_ms >= $1
AND r.finished_ms < $2
GROUP BY r.track_id, rd.driver_id
),
ranked_times AS (
SELECT
track_id,
driver_id,
best_time,
ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY best_time ASC NULLS LAST, driver_id ASC) as time_rank
FROM best_results
),
track_points AS (
SELECT
track_id,
driver_id,
best_time,
CASE
WHEN best_time IS NULL THEN 1
WHEN time_rank <= 3 THEN 25
WHEN time_rank <= 10 THEN 18
WHEN time_rank <= 20 THEN 15
WHEN time_rank <= 30 THEN 12
WHEN time_rank <= 40 THEN 10
WHEN time_rank <= 50 THEN 8
WHEN time_rank <= 60 THEN 6
WHEN time_rank <= 70 THEN 4
WHEN time_rank <= 80 THEN 2
WHEN time_rank <= 90 THEN 1
ELSE 1
END as points
FROM ranked_times
),
overall_points AS (
SELECT
driver_id,
SUM(points) as points
FROM track_points
GROUP BY driver_id
),
ranked_overall AS (
SELECT
op.driver_id,
d.name,
d.nickname,
d.avatar_url,
COALESCE(d.clan_id, '') as clan_id,
COALESCE(c.tag, '') as clan_tag,
op.points,
ROW_NUMBER() OVER (ORDER BY op.points DESC, op.driver_id ASC) as rank
FROM overall_points op
JOIN drivers d ON op.driver_id = d.id
LEFT JOIN clans c ON d.clan_id = c.id
)
SELECT rank, driver_id, name, nickname, avatar_url, clan_id, clan_tag, points
FROM ranked_overall
WHERE driver_id = $3`
row := s.pool.QueryRow(ctx, query, startMs, endMs, driverID)
var entry LeaderboardEntry
if err := row.Scan(
&entry.Rank, &entry.DriverID, &entry.Name, &entry.Nickname,
&entry.AvatarURL, &entry.ClanID, &entry.ClanTag, &entry.Points,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &entry, nil
}
// GetDriverLeaderboardProfile returns driver profile information.
func (s *PgStore) GetDriverLeaderboardProfile(ctx context.Context, driverID string) (*LeaderboardEntry, error) {
query := `
SELECT d.id, d.name, d.nickname, d.avatar_url, COALESCE(d.clan_id, ''), COALESCE(c.tag, '')
FROM drivers d
LEFT JOIN clans c ON d.clan_id = c.id
WHERE d.id = $1`
row := s.pool.QueryRow(ctx, query, driverID)
var entry LeaderboardEntry
if err := row.Scan(
&entry.DriverID, &entry.Name, &entry.Nickname, &entry.AvatarURL, &entry.ClanID, &entry.ClanTag,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &entry, nil
}
// TrackCalendarInfo holds the basic details of a scheduled track.
type TrackCalendarInfo struct {
ID int
TrackID string
TrackName string
StartAt time.Time
EndAt time.Time
}
// TrackCalendarLeaderboard holds the leaderboard summary for a track calendar event.
type TrackCalendarLeaderboard struct {
TrackID string
TrackName string
StartAt time.Time
EndAt time.Time
Status string // finished, active, planned
Podium []LeaderboardEntry
CurrentDriver *LeaderboardEntry
}
// GetCalendarLeaderboardTracks returns all track calendar entries joined with their track names, sorted chronologically.
func (s *PgStore) GetCalendarLeaderboardTracks(ctx context.Context) ([]TrackCalendarInfo, error) {
rows, err := s.pool.Query(ctx, `
SELECT tc.id, tc.track_id, t.name, tc.start_at, tc.end_at
FROM track_calendar tc
JOIN tracks t ON tc.track_id = t.id
ORDER BY tc.start_at DESC
`)
if err != nil {
return nil, fmt.Errorf("query calendar tracks: %w", err)
}
defer rows.Close()
var out []TrackCalendarInfo
for rows.Next() {
var info TrackCalendarInfo
if err := rows.Scan(&info.ID, &info.TrackID, &info.TrackName, &info.StartAt, &info.EndAt); err != nil {
return nil, fmt.Errorf("scan calendar track: %w", err)
}
out = append(out, info)
}
return out, nil
}
+310
View File
@@ -0,0 +1,310 @@
package races
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestLeaderboard(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL is not set, skipping database integration tests")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("failed to connect to test database: %v", err)
}
defer pool.Close()
store := NewPgStore(pool)
// Clean up any left-overs with our test prefix
cleanup := func() {
_, _ = pool.Exec(ctx, "DELETE FROM race_drivers WHERE race_id LIKE 'test-race-%'")
_, _ = pool.Exec(ctx, "DELETE FROM races WHERE id LIKE 'test-race-%'")
_, _ = pool.Exec(ctx, "DELETE FROM drivers WHERE id LIKE 'test-driver-%'")
_, _ = pool.Exec(ctx, "DELETE FROM clans WHERE id LIKE 'test-clan-%'")
}
cleanup()
defer cleanup()
// 1. Create a test clan and test drivers
nowMs := time.Now().UnixMilli()
_, err = pool.Exec(ctx, `
INSERT INTO clans (id, tag, name, avatar_url, created_ms, updated_ms)
VALUES ('test-clan-1', 'TST', 'Test Clan', 'http://avatar.clan', $1, $1)`, nowMs)
if err != nil {
t.Fatalf("failed to insert test clan: %v", err)
}
driversData := []struct {
id string
nick string
name string
}{
{"test-driver-1", "DRV", "Driver One"},
{"test-driver-2", "TWO", "Driver Two"},
{"test-driver-3", "THR", "Driver Three"},
{"test-driver-4", "FOR", "Driver Four"},
}
for _, d := range driversData {
_, err = pool.Exec(ctx, `
INSERT INTO drivers (id, nickname, name, avatar_url, clan_id, created_ms, updated_ms)
VALUES ($1, $2, $3, 'http://avatar', 'test-clan-1', $4, $4)`,
d.id, d.nick, d.name, nowMs)
if err != nil {
t.Fatalf("failed to insert test driver %s: %v", d.id, err)
}
}
// 2. Create test races on different tracks for current year
currentYear := time.Now().Year()
testYearMs := time.Date(currentYear, time.June, 1, 12, 0, 0, 0, time.UTC).UnixMilli()
racesData := []struct {
id string
trackID string
}{
{"test-race-1", "test-track-A"},
{"test-race-2", "test-track-A"},
{"test-race-3", "test-track-B"},
}
for _, r := range racesData {
_, err = pool.Exec(ctx, `
INSERT INTO races (id, name, track_id, max_cars, laps, time_limit_s, status, created_ms, started_ms, finished_ms, updated_ms)
VALUES ($1, 'Test Race', $2, 4, 10, 60, 'finished', $3, $3, $3, $3)`,
r.id, r.trackID, testYearMs)
if err != nil {
t.Fatalf("failed to insert test race %s: %v", r.id, err)
}
}
// 3. Insert race drivers results
// Race 1 (Track A):
// - test-driver-1: best_lap 100 (1st fastest -> 25 points)
// - test-driver-2: best_lap 400 (currently 3rd fastest -> 25 points)
// - test-driver-3: best_lap 300 (currently 2nd fastest -> 25 points)
// - test-driver-4: best_lap 0 (DNF, completed 0 laps -> 1 point)
resultsRace1 := []struct {
driverID string
bestLapMs int64
pos any
}{
{"test-driver-1", 100, 1},
{"test-driver-2", 400, 2},
{"test-driver-3", 300, 3},
{"test-driver-4", 0, nil},
}
for _, res := range resultsRace1 {
_, err = pool.Exec(ctx, `
INSERT INTO race_drivers (race_id, driver_id, slot, joined_ms, total_time_ms, best_lap_ms, position)
VALUES ('test-race-1', $1, 0, $2, 1000, $3, $4)`,
res.driverID, testYearMs, res.bestLapMs, res.pos)
if err != nil {
t.Fatalf("failed to insert race driver result: %v", err)
}
}
// Race 2 (Track A - testing best results):
// - test-driver-2: best_lap 150 (2nd fastest -> 25 points, overrides 400ms lap time)
// This makes the times:
// - test-driver-1: 100ms (rank 1 -> 25 points)
// - test-driver-2: 150ms (rank 2 -> 25 points)
// - test-driver-3: 300ms (rank 3 -> 25 points)
// - test-driver-4: NULL (rank 4 -> 1 point)
// Exactly 3 drivers have 25 points.
_, err = pool.Exec(ctx, `
INSERT INTO race_drivers (race_id, driver_id, slot, joined_ms, total_time_ms, best_lap_ms, position)
VALUES ('test-race-2', 'test-driver-2', 0, $1, 1000, 150, 1)`, testYearMs)
if err != nil {
t.Fatalf("failed to insert race 2 result: %v", err)
}
// Race 3 (Track B):
// - test-driver-1: best_lap 500 (rank 2 on Track B -> 25 points)
// - test-driver-3: best_lap 200 (rank 1 on Track B -> 25 points)
resultsRace3 := []struct {
driverID string
bestLapMs int64
pos any
}{
{"test-driver-1", 500, 2},
{"test-driver-3", 200, 1},
}
for _, res := range resultsRace3 {
_, err = pool.Exec(ctx, `
INSERT INTO race_drivers (race_id, driver_id, slot, joined_ms, total_time_ms, best_lap_ms, position)
VALUES ('test-race-3', $1, 0, $2, 1000, $3, $4)`,
res.driverID, testYearMs, res.bestLapMs, res.pos)
if err != nil {
t.Fatalf("failed to insert race 3 driver result: %v", err)
}
}
// Test Track A Leaderboard
t.Run("Track A Leaderboard", func(t *testing.T) {
entries, total, err := store.GetTrackLeaderboard(ctx, "test-track-A", currentYear, 10, 0)
if err != nil {
t.Fatalf("failed to get track leaderboard: %v", err)
}
if total != 4 {
t.Errorf("expected total 4, got %d", total)
}
if len(entries) != 4 {
t.Fatalf("expected 4 entries, got %d", len(entries))
}
// Rank 1: test-driver-1 (25 pts, best_time 100ms)
// Rank 2: test-driver-2 (25 pts, best_time 150ms)
// Rank 3: test-driver-3 (25 pts, best_time 300ms)
// Rank 4: test-driver-4 (1 pt, best_time NULL)
drv1 := entries[0]
if drv1.DriverID != "test-driver-1" || drv1.Points != 25 || *drv1.BestTimeMs != 100 || drv1.Rank != 1 {
t.Errorf("invalid rank 1: %+v", drv1)
}
drv2 := entries[1]
if drv2.DriverID != "test-driver-2" || drv2.Points != 25 || *drv2.BestTimeMs != 150 || drv2.Rank != 2 {
t.Errorf("invalid rank 2: %+v", drv2)
}
// Check entries by looping to find specific drivers
for _, e := range entries {
if e.DriverID == "test-driver-3" {
if e.Points != 25 || *e.BestTimeMs != 300 || e.Rank != 3 {
t.Errorf("invalid test-driver-3: %+v", e)
}
}
if e.DriverID == "test-driver-4" {
if e.Points != 1 || e.BestTimeMs != nil || e.Rank != 4 {
t.Errorf("invalid test-driver-4: %+v", e)
}
}
}
})
// Test Track A Driver Rank
t.Run("Track A Driver Rank", func(t *testing.T) {
entry, err := store.GetTrackLeaderboardDriver(ctx, "test-track-A", currentYear, "test-driver-2")
if err != nil {
t.Fatalf("failed to get driver rank: %v", err)
}
if entry == nil || entry.Rank != 2 || entry.Points != 25 || *entry.BestTimeMs != 150 {
t.Errorf("expected rank 2, points 25, best_time 150, got %+v", entry)
}
})
// Test Overall Leaderboard
t.Run("Overall Leaderboard", func(t *testing.T) {
// Overall points calculation:
// - test-driver-1: Track A (25) + Track B (25) = 50 points
// - test-driver-3: Track A (25) + Track B (25) = 50 points
// - test-driver-2: Track A (25) + Track B (0) = 25 points
// - test-driver-4: Track A (1) + Track B (0) = 1 point
entries, _, err := store.GetOverallLeaderboard(ctx, currentYear, 200, 0)
if err != nil {
t.Fatalf("failed to get overall leaderboard: %v", err)
}
// Filter to only our test drivers
var testEntries []LeaderboardEntry
for _, entry := range entries {
if len(entry.DriverID) >= 11 && entry.DriverID[:12] == "test-driver-" {
testEntries = append(testEntries, entry)
}
}
if len(testEntries) != 4 {
t.Fatalf("expected 4 test entries, got %d", len(testEntries))
}
// test-driver-1 and test-driver-3 should both have 50 points
if testEntries[0].Points != 50 || testEntries[1].Points != 50 {
t.Errorf("expected top 2 test drivers to have 50 points, got: %+v and %+v", testEntries[0], testEntries[1])
}
if testEntries[2].DriverID != "test-driver-2" || testEntries[2].Points != 25 {
t.Errorf("invalid overall rank 3: %+v", testEntries[2])
}
if testEntries[3].DriverID != "test-driver-4" || testEntries[3].Points != 1 {
t.Errorf("invalid overall rank 4: %+v", testEntries[3])
}
})
// Test Overall Driver Rank
t.Run("Overall Driver Rank", func(t *testing.T) {
entry, err := store.GetOverallLeaderboardDriver(ctx, currentYear, "test-driver-3")
if err != nil {
t.Fatalf("failed to get overall driver rank: %v", err)
}
if entry == nil || entry.Points != 50 || entry.Rank <= 0 {
t.Errorf("expected rank > 0, points 50, got %+v", entry)
}
})
// Test Fallback Profile
t.Run("Fallback Profile", func(t *testing.T) {
entry, err := store.GetDriverLeaderboardProfile(ctx, "test-driver-1")
if err != nil {
t.Fatalf("failed to get profile: %v", err)
}
if entry == nil || entry.Name != "Driver One" || entry.Nickname != "DRV" || entry.ClanTag != "TST" {
t.Errorf("expected Driver One, DRV, TST, got %+v", entry)
}
})
// Test Calendar Leaderboard
t.Run("Calendar Leaderboard", func(t *testing.T) {
_, _ = pool.Exec(ctx, "DELETE FROM track_calendar")
now := time.Now()
_, err = pool.Exec(ctx, `
INSERT INTO track_calendar (track_id, start_at, end_at) VALUES
('monaco', $1, $2),
('barcelona-catalunya', $3, $4),
('austria-redbull-ring', $5, $6)
`,
now.Add(-10*time.Hour), now.Add(-5*time.Hour),
now.Add(-2*time.Hour), now.Add(2*time.Hour),
now.Add(5*time.Hour), now.Add(10*time.Hour),
)
if err != nil {
t.Fatalf("failed to insert track calendar: %v", err)
}
defer func() {
_, _ = pool.Exec(ctx, "DELETE FROM track_calendar")
}()
svc := NewService(store, nil)
svc.now = func() time.Time { return now }
res, err := svc.GetCalendarLeaderboard(ctx, currentYear, "test-driver-1")
if err != nil {
t.Fatalf("failed to get calendar leaderboard: %v", err)
}
if len(res) != 3 {
t.Fatalf("expected 3 calendar entries, got %d", len(res))
}
// 1. Monaco (finished)
if res[0].TrackID != "monaco" || res[0].Status != "finished" {
t.Errorf("expected monaco (finished), got %s (%s)", res[0].TrackID, res[0].Status)
}
// 2. Barcelona (active)
if res[1].TrackID != "barcelona-catalunya" || res[1].Status != "active" {
t.Errorf("expected barcelona-catalunya (active), got %s (%s)", res[1].TrackID, res[1].Status)
}
// 3. Austria (planned)
if res[2].TrackID != "austria-redbull-ring" || res[2].Status != "planned" {
t.Errorf("expected austria-redbull-ring (planned), got %s (%s)", res[2].TrackID, res[2].Status)
}
})
}
+74 -7
View File
@@ -29,8 +29,6 @@ var DefaultNow = time.Now
// 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
@@ -43,6 +41,18 @@ var defaultDriverSeeds = []struct {
{"FRA", "Frank"},
{"GRA", "Grace"},
{"HEI", "Heidi"},
{"IVY", "Ivy"},
{"JAC", "Jack"},
{"KEN", "Ken"},
{"LEO", "Leo"},
{"MAX", "Max"},
{"NED", "Ned"},
{"OLI", "Oliver"},
{"PEP", "Peggy"},
{"QIN", "Quincy"},
{"ROY", "Roy"},
{"SAM", "Sam"},
{"TED", "Ted"},
}
// Default clans.
@@ -226,10 +236,54 @@ func (r *Runner) Run(ctx context.Context, opt Options) (Summary, error) {
s.PlansInserted++
}
if err := r.seedTrackCalendar(ctx); err != nil {
return s, fmt.Errorf("seed track calendar: %w", err)
}
s.Duration = time.Since(start)
return s, nil
}
func (r *Runner) seedTrackCalendar(ctx context.Context) error {
now := r.now
if len(r.trackIDs) == 0 {
return nil
}
tracksMap := make(map[string]bool)
for _, id := range r.trackIDs {
tracksMap[id] = true
}
schedule := []struct {
trackID string
startAt time.Time
endAt time.Time
}{
{"monaco", now.Add(-14 * 24 * time.Hour), now.Add(-7 * 24 * time.Hour)},
{"barcelona-catalunya", now.Add(-2 * 24 * time.Hour), now.Add(12 * 24 * time.Hour)},
{"austria-redbull-ring", now.Add(14 * 24 * time.Hour), now.Add(28 * 24 * time.Hour)},
{"great-britain-silverstone", now.Add(28 * 24 * time.Hour), now.Add(42 * 24 * time.Hour)},
{"belgium-spa", now.Add(42 * 24 * time.Hour), now.Add(56 * 24 * time.Hour)},
}
for _, s := range schedule {
if !tracksMap[s.trackID] {
continue
}
_, err := r.pg.Exec(ctx, `
INSERT INTO track_calendar (track_id, start_at, end_at)
VALUES ($1, $2, $3)
`, s.trackID, s.startAt, s.endAt)
if err != nil {
return fmt.Errorf("insert calendar entry: %w", err)
}
}
return nil
}
type planShape struct {
intervalS int
count int
@@ -241,18 +295,31 @@ type planShape struct {
// 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 {
if _, err := r.pg.Exec(ctx, `DELETE FROM race_queue`); err != nil {
return err
}
if _, err := r.pg.Exec(ctx,
`TRUNCATE TABLE race_plans, race_queue`); err != nil {
if _, err := r.pg.Exec(ctx, `DELETE FROM races`); err != nil {
return err
}
if _, err := r.pg.Exec(ctx, `DELETE FROM race_plans`); err != nil {
return err
}
if _, err := r.pg.Exec(ctx, `DELETE FROM track_calendar`); err != nil {
return err
}
if _, err := r.pg.Exec(ctx, `DELETE FROM drivers`); err != nil {
return err
}
if _, err := r.pg.Exec(ctx, `DELETE FROM clans`); err != nil {
return err
}
if _, err := r.pg.Exec(ctx, `ALTER SEQUENCE IF EXISTS track_calendar_id_seq RESTART WITH 1`); 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 {
if _, err := r.pg.Exec(ctx, `DELETE FROM lobby_drivers`); err != nil {
return err
}
return nil
+127
View File
@@ -685,6 +685,133 @@ func (s *Service) DeletePlan(ctx context.Context, id string) error {
return s.pg.DeletePlan(ctx, id)
}
// GetLeaderboard retrieves the leaderboard page and current driver position.
func (s *Service) GetLeaderboard(ctx context.Context, trackID string, year int, limit, offset int, currentDriverID string) (*LeaderboardResult, error) {
if year == 0 {
year = s.now().Year()
}
var items []LeaderboardEntry
var total int
var err error
if trackID != "" {
items, total, err = s.pg.GetTrackLeaderboard(ctx, trackID, year, limit, offset)
} else {
items, total, err = s.pg.GetOverallLeaderboard(ctx, year, limit, offset)
}
if err != nil {
return nil, err
}
var currentDriver *LeaderboardEntry
if currentDriverID != "" {
if trackID != "" {
currentDriver, err = s.pg.GetTrackLeaderboardDriver(ctx, trackID, year, currentDriverID)
} else {
currentDriver, err = s.pg.GetOverallLeaderboardDriver(ctx, year, currentDriverID)
}
if err != nil {
return nil, err
}
if currentDriver == nil {
// fallback: get profile from drivers table
currentDriver, err = s.pg.GetDriverLeaderboardProfile(ctx, currentDriverID)
if err != nil {
return nil, err
}
// if driver exists but has no points, set points/rank to 0
if currentDriver != nil {
currentDriver.Points = 0
currentDriver.Rank = 0
}
}
}
if items == nil {
items = []LeaderboardEntry{}
}
return &LeaderboardResult{
Items: items,
Total: total,
CurrentDriver: currentDriver,
}, nil
}
// GetCalendarLeaderboard returns the leaderboard details of all scheduled tracks.
func (s *Service) GetCalendarLeaderboard(ctx context.Context, year int, currentDriverID string) ([]TrackCalendarLeaderboard, error) {
if year == 0 {
year = s.now().Year()
}
calendarInfo, err := s.pg.GetCalendarLeaderboardTracks(ctx)
if err != nil {
return nil, err
}
now := s.now()
var out []TrackCalendarLeaderboard
// Cache driver profile fallback if we need it
var fallbackProfile *LeaderboardEntry
if currentDriverID != "" {
fallbackProfile, err = s.pg.GetDriverLeaderboardProfile(ctx, currentDriverID)
if err != nil {
return nil, err
}
if fallbackProfile != nil {
fallbackProfile.Points = 0
fallbackProfile.Rank = 0
}
}
for _, cal := range calendarInfo {
status := "planned"
if now.After(cal.EndAt) {
status = "finished"
} else if (now.Equal(cal.StartAt) || now.After(cal.StartAt)) && now.Before(cal.EndAt) {
status = "active"
}
// Get top 3
podium, _, err := s.pg.GetTrackLeaderboard(ctx, cal.TrackID, year, 3, 0)
if err != nil {
return nil, err
}
if podium == nil {
podium = []LeaderboardEntry{}
}
// Get current driver
var curDriver *LeaderboardEntry
if currentDriverID != "" {
curDriver, err = s.pg.GetTrackLeaderboardDriver(ctx, cal.TrackID, year, currentDriverID)
if err != nil {
return nil, err
}
if curDriver == nil && fallbackProfile != nil {
// Clone the fallback profile so we don't share pointer/modify original
profileCopy := *fallbackProfile
curDriver = &profileCopy
}
}
out = append(out, TrackCalendarLeaderboard{
TrackID: cal.TrackID,
TrackName: cal.TrackName,
StartAt: cal.StartAt,
EndAt: cal.EndAt,
Status: status,
Podium: podium,
CurrentDriver: curDriver,
})
}
return out, nil
}
// ---------------------------------------------------------------------------
// Scheduler
// ---------------------------------------------------------------------------
+2 -2
View File
@@ -112,8 +112,8 @@ func NewPgStore(pool *pgxpool.Pool) *PgStore {
}
// Exec exposes a raw Exec for the seeder / maintenance scripts.
func (s *PgStore) Exec(ctx context.Context, sql string) (int64, error) {
tag, err := s.pool.Exec(ctx, sql)
func (s *PgStore) Exec(ctx context.Context, sql string, args ...any) (int64, error) {
tag, err := s.pool.Exec(ctx, sql, args...)
if err != nil {
return 0, err
}
@@ -0,0 +1,11 @@
-- 014_track_calendar.sql — schema for F1-style active track scheduling.
CREATE TABLE IF NOT EXISTS track_calendar (
id SERIAL PRIMARY KEY,
track_id TEXT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
start_at TIMESTAMP WITH TIME ZONE NOT NULL,
end_at TIMESTAMP WITH TIME ZONE NOT NULL,
CHECK (start_at < end_at)
);
CREATE INDEX IF NOT EXISTS track_calendar_dates_idx ON track_calendar (start_at, end_at);
@@ -371,3 +371,51 @@ func (s *PgStore) UpdateCarStats(ctx context.Context, id string, fn func(*catalo
}
return tx.Commit(ctx)
}
// GetTrackCalendar returns all track calendar entries ordered by start_at.
func (s *PgStore) GetTrackCalendar(ctx context.Context) ([]catalog.TrackCalendarEntry, error) {
query := `SELECT id, track_id, start_at, end_at FROM track_calendar ORDER BY start_at ASC`
rows, err := s.pool.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []catalog.TrackCalendarEntry
for rows.Next() {
var entry catalog.TrackCalendarEntry
if err := rows.Scan(&entry.ID, &entry.TrackID, &entry.StartAt, &entry.EndAt); err != nil {
return nil, err
}
entries = append(entries, entry)
}
return entries, nil
}
// CreateTrackCalendar inserts a new calendar entry.
func (s *PgStore) CreateTrackCalendar(ctx context.Context, entry catalog.TrackCalendarEntry) (catalog.TrackCalendarEntry, error) {
query := `
INSERT INTO track_calendar (track_id, start_at, end_at)
VALUES ($1, $2, $3)
RETURNING id, track_id, start_at, end_at`
row := s.pool.QueryRow(ctx, query, entry.TrackID, entry.StartAt, entry.EndAt)
var res catalog.TrackCalendarEntry
if err := row.Scan(&res.ID, &res.TrackID, &res.StartAt, &res.EndAt); err != nil {
return catalog.TrackCalendarEntry{}, err
}
return res, nil
}
// DeleteTrackCalendar deletes a calendar entry by ID.
func (s *PgStore) DeleteTrackCalendar(ctx context.Context, id int) error {
command := `DELETE FROM track_calendar WHERE id = $1`
res, err := s.pool.Exec(ctx, command, id)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
return catalog.ErrNotFound
}
return nil
}
+73 -1
View File
@@ -239,6 +239,19 @@ type VersionResponse struct {
Version string `json:"version" example:"0.1.0-poc"`
}
// WebRTCConnectRequest is the body of the WebRTC connection API.
type WebRTCConnectRequest struct {
SDP string `json:"sdp"`
Type string `json:"type" example:"offer"`
DriverID string `json:"driver_id" example:"driver-alice"`
}
// WebRTCConnectResponse is the response to WebRTCConnectRequest.
type WebRTCConnectResponse struct {
SDP string `json:"sdp"`
Type string `json:"type" example:"answer"`
}
// LobbyDriver is the wire form of lobby.DriverMeta.
type LobbyDriver struct {
ID string `json:"id"`
@@ -249,7 +262,6 @@ type LobbyDriver struct {
LastSeenMs int64 `json:"last_seen_ms"`
Country string `json:"country,omitempty"`
AvatarHue int `json:"avatar_hue,omitempty"`
DeviceID *int `json:"device_id,omitempty"`
}
// LobbySnapshot is the full lobby state, broadcast on change.
@@ -350,6 +362,7 @@ type TrackWire struct {
BestLapHolder string `json:"best_lap_holder,omitempty"`
CreatedMs int64 `json:"created_ms"`
UpdatedMs int64 `json:"updated_ms"`
IsActive bool `json:"is_active" example:"true"`
}
// TrackListRequest: payload { "scope": "all|system|public|private", "tag": "..." }.
@@ -827,6 +840,65 @@ type RaceResultMsg struct {
FinishedAtMs int64 `json:"finished_at_ms"`
}
// LeaderboardEntry is a single row in the paginated leaderboard response.
type LeaderboardEntry struct {
DriverID string `json:"driver_id" example:"driver-seed-001"`
Nickname string `json:"nickname" example:"ACE"`
Name string `json:"name" example:"Alice"`
AvatarURL string `json:"avatar_url" example:"https://cdn.example.com/u/ace.png"`
ClanID string `json:"clan_id,omitempty" example:"clan-seed-001"`
ClanTag string `json:"clan_tag,omitempty" example:"ACE"`
Points int `json:"points" example:"25"`
BestPos *int `json:"best_pos,omitempty" example:"1"` // Only populated for track-specific leaderboard
BestTimeMs *int64 `json:"best_time_ms,omitempty" example:"15000"`
Rank int `json:"rank" example:"1"`
}
// LeaderboardResponse is the paginated response for GET /api/leaderboard.
type LeaderboardResponse struct {
Items []LeaderboardEntry `json:"items"`
Total int `json:"total" example:"8"`
Limit int `json:"limit" example:"50"`
Offset int `json:"offset" example:"0"`
CurrentDriver *LeaderboardEntry `json:"current_driver,omitempty"`
}
// TrackCalendarEntry is the wire representation of an active track schedule period.
type TrackCalendarEntry struct {
ID int `json:"id" example:"1"`
TrackID string `json:"track_id" example:"monaco"`
StartAt time.Time `json:"start_at" example:"2026-07-15T00:00:00Z"`
EndAt time.Time `json:"end_at" example:"2026-07-30T00:00:00Z"`
}
// TrackCalendarCreateRequest is the payload to schedule a track active period.
type TrackCalendarCreateRequest struct {
TrackID string `json:"track_id" example:"monaco"`
StartAt time.Time `json:"start_at" example:"2026-07-15T00:00:00Z"`
EndAt time.Time `json:"end_at" example:"2026-07-30T00:00:00Z"`
}
// TrackCalendarResponse is the wrapper for the calendar list.
type TrackCalendarResponse struct {
Items []TrackCalendarEntry `json:"items"`
}
// TrackCalendarLeaderboard holds the leaderboard summary for a track calendar event.
type TrackCalendarLeaderboard struct {
TrackID string `json:"track_id" example:"monaco"`
TrackName string `json:"track_name" example:"Monaco"`
StartAt time.Time `json:"start_at" example:"2026-07-15T00:00:00Z"`
EndAt time.Time `json:"end_at" example:"2026-07-30T00:00:00Z"`
Status string `json:"status" example:"active"`
Podium []LeaderboardEntry `json:"podium"`
CurrentDriver *LeaderboardEntry `json:"current_driver,omitempty"`
}
// TrackCalendarLeaderboardResponse is the list response for calendar leaderboards.
type TrackCalendarLeaderboardResponse struct {
Items []TrackCalendarLeaderboard `json:"items"`
}
// Encode marshals an envelope to JSON bytes.
func Encode(env *Envelope) ([]byte, error) {
if env.TSMs == 0 {
+760
View File
@@ -0,0 +1,760 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>x0gp · WebRTC Cockpit</title>
<!-- Modern typography -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color: #0b0f19;
--panel-bg: rgba(17, 24, 39, 0.7);
--border-color: rgba(255, 255, 255, 0.08);
--primary: #10b981; /* Emerald */
--primary-hover: #059669;
--accent: #3b82f6; /* Blue */
--text-main: #f3f4f6;
--text-muted: #9ca3af;
--danger: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Outfit', sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
background-image:
radial-gradient(circle at 10% 20%, rgba(16, 185, 129, 0.05) 0%, transparent 40%),
radial-gradient(circle at 90% 80%, rgba(59, 130, 246, 0.05) 0%, transparent 40%);
}
header {
padding: 1.5rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
backdrop-filter: blur(10px);
z-index: 10;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 800;
font-size: 1.5rem;
letter-spacing: -0.05em;
background: linear-gradient(135deg, var(--primary), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.status-badge {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
background-color: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--danger);
box-shadow: 0 0 8px var(--danger);
transition: all 0.3s ease;
}
.status-dot.connected {
background-color: var(--primary);
box-shadow: 0 0 8px var(--primary);
}
.status-dot.connecting {
background-color: #f59e0b;
box-shadow: 0 0 8px #f59e0b;
animation: pulse 1s infinite alternate;
}
@keyframes pulse {
from { opacity: 0.5; }
to { opacity: 1; }
}
main {
flex: 1;
padding: 2rem;
max-width: 1400px;
width: 100%;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
@media (min-width: 1024px) {
main {
grid-template-columns: 1.6fr 1fr;
}
}
.glass-panel {
background: var(--panel-bg);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 1.5rem;
backdrop-filter: blur(16px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
transition: border-color 0.3s ease;
}
.glass-panel:hover {
border-color: rgba(255, 255, 255, 0.12);
}
h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-main);
}
.auth-bar {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
input {
flex: 1;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem 1rem;
color: var(--text-main);
font-family: inherit;
font-size: 0.95rem;
transition: all 0.2s ease;
}
input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.15);
}
button {
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
border: none;
border-radius: 8px;
padding: 0.75rem 1.5rem;
color: white;
font-family: inherit;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.3);
}
button:active {
transform: translateY(0);
}
button.disconnect {
background: linear-gradient(135deg, var(--danger), #dc2626);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
}
.video-container {
position: relative;
width: 100%;
aspect-ratio: 4/3;
background: #020617;
border-radius: 12px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.04);
box-shadow: inset 0 0 40px rgba(0,0,0,0.8);
}
.video-feed {
width: 100%;
height: 100%;
object-fit: contain;
display: none;
}
.video-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
color: var(--text-muted);
text-align: center;
padding: 2rem;
}
.video-placeholder svg {
width: 48px;
height: 48px;
stroke: var(--text-muted);
opacity: 0.6;
animation: bounce 2s infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.telemetry-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.telemetry-card {
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 1rem;
text-align: center;
}
.telemetry-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.telemetry-value {
font-size: 1.5rem;
font-weight: 800;
font-family: 'JetBrains Mono', monospace;
color: var(--primary);
}
.controller-viz {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
}
.steering-wheel-container {
position: relative;
width: 120px;
height: 120px;
}
.steering-wheel {
width: 100%;
height: 100%;
transition: transform 0.1s ease-out;
transform-origin: center center;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.5));
}
.keys-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
width: 160px;
}
.key-btn {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
border-radius: 8px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-family: 'JetBrains Mono', monospace;
color: var(--text-muted);
transition: all 0.1s ease;
user-select: none;
}
.key-btn.active {
background: var(--primary);
color: #0b0f19;
box-shadow: 0 0 12px var(--primary);
border-color: var(--primary);
}
.key-btn.empty {
opacity: 0;
pointer-events: none;
}
.console {
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 1rem;
height: 180px;
overflow-y: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
color: #10b981;
box-shadow: inset 0 0 10px rgba(0,0,0,0.5);
}
.console-entry {
margin-bottom: 0.35rem;
line-height: 1.3;
}
.console-time {
color: var(--text-muted);
}
.console-in {
color: var(--accent);
}
.console-out {
color: var(--primary);
}
footer {
padding: 1.5rem;
text-align: center;
font-size: 0.85rem;
color: var(--text-muted);
border-top: 1px solid var(--border-color);
margin-top: auto;
}
</style>
</head>
<body>
<header>
<div class="logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 11 22 2 13 21 11 13 3 11"/></svg>
x0gp <span style="font-weight: 300;">Cockpit</span>
</div>
<div class="status-badge">
<div id="statusDot" class="status-dot"></div>
<span id="statusText">Disconnected</span>
</div>
</header>
<main>
<!-- Left: Video Feed Panel -->
<div class="glass-panel" style="display: flex; flex-direction: column;">
<h2>
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
Real-Time Video Feed
</h2>
<div class="video-container">
<img id="videoFeed" class="video-feed" alt="WebRTC Stream">
<div id="videoPlaceholder" class="video-placeholder">
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.182 16.318A4.486 4.486 0 0012.016 15a4.486 4.486 0 00-3.198 1.318M21 12a9 9 0 11-18 0 9 9 0 0118 0zM9.75 9.75c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75zm-.375 0h.008v.015h-.008V9.75zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75zm-.375 0h.008v.015h-.008V9.75z"/></svg>
<p>Connect the cockpit to receive real-time video</p>
</div>
</div>
<div class="controller-viz">
<div class="steering-wheel-container">
<!-- Steering wheel SVG -->
<svg id="steeringWheel" class="steering-wheel" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="45" stroke="#f3f4f6" stroke-width="6"/>
<circle cx="50" cy="50" r="38" stroke="var(--primary)" stroke-width="2" stroke-dasharray="5 5"/>
<rect x="15" y="47" width="70" height="6" rx="3" fill="#f3f4f6"/>
<rect x="47" y="50" width="6" height="35" rx="3" fill="#f3f4f6"/>
<circle cx="50" cy="50" r="10" fill="#0b0f19" stroke="#f3f4f6" stroke-width="3"/>
</svg>
</div>
<div class="keys-grid">
<div class="key-btn empty"></div>
<div id="keyW" class="key-btn">W</div>
<div class="key-btn empty"></div>
<div id="keyA" class="key-btn">A</div>
<div id="keyS" class="key-btn">S</div>
<div id="keyD" class="key-btn">D</div>
</div>
</div>
</div>
<!-- Right: Telemetry & Controls Panel -->
<div class="glass-panel" style="display: flex; flex-direction: column; gap: 1.5rem;">
<div>
<h2>
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Session Control
</h2>
<div class="auth-bar">
<input type="text" id="driverIdInput" placeholder="Enter Driver ID (e.g. ACE)" value="ACE">
<button id="connectBtn">Connect</button>
</div>
</div>
<div>
<h2>
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 002 2h2a2 2 0 002-2z"/></svg>
Real-time Telemetry
</h2>
<div class="telemetry-grid">
<div class="telemetry-card">
<div class="telemetry-label">Speed</div>
<div id="telSpeed" class="telemetry-value">0.00 <span style="font-size: 0.8rem; font-weight: normal;">m/s</span></div>
</div>
<div class="telemetry-card">
<div class="telemetry-label">Ping Latency</div>
<div id="telPing" class="telemetry-value">0 <span style="font-size: 0.8rem; font-weight: normal;">ms</span></div>
</div>
<div class="telemetry-card">
<div class="telemetry-label">Car ID</div>
<div id="telDevice" class="telemetry-value">-</div>
</div>
<div class="telemetry-card">
<div class="telemetry-label">Position (X, Y)</div>
<div id="telPos" class="telemetry-value">0.0, 0.0</div>
</div>
</div>
</div>
<div style="flex: 1; display: flex; flex-direction: column;">
<h2>
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
Console Log
</h2>
<div id="consoleLog" class="console">
<div class="console-entry"><span class="console-time">[18:58:00]</span> Welcome to x0gp Cockpit. Enter Driver ID and click Connect to initialize WebRTC.</div>
</div>
</div>
</div>
</main>
<footer>
x0gp · Pure Go WebRTC 1-to-1 Proxy System
</footer>
<script>
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const driverIdInput = document.getElementById('driverIdInput');
const connectBtn = document.getElementById('connectBtn');
const videoFeed = document.getElementById('videoFeed');
const videoPlaceholder = document.getElementById('videoPlaceholder');
const consoleLog = document.getElementById('consoleLog');
const steeringWheel = document.getElementById('steeringWheel');
// Telemetry DOM
const telSpeed = document.getElementById('telSpeed');
const telPing = document.getElementById('telPing');
const telDevice = document.getElementById('telDevice');
const telPos = document.getElementById('telPos');
let pc = null;
let dataChannel = null;
let videoChannel = null;
let sendControlsInterval = null;
let activeKeys = { w: false, a: false, s: false, d: false };
let currentSteer = 0; // -1 to 1
let currentThrottle = 0; // 0 to 1
let currentBrake = 0; // 0 to 1
let pingsReceived = 0;
let latencyStart = 0;
function log(message, type = 'system') {
const time = new Date().toTimeString().split(' ')[0];
const div = document.createElement('div');
div.className = 'console-entry';
let prefix = '';
if (type === 'in') prefix = ' <span class="console-in">&lt;-</span> ';
if (type === 'out') prefix = ' <span class="console-out">-&gt;</span> ';
div.innerHTML = `<span class="console-time">[${time}]</span>${prefix}${message}`;
consoleLog.appendChild(div);
consoleLog.scrollTop = consoleLog.scrollHeight;
}
// Keyboard event listeners
window.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
if (activeKeys.hasOwnProperty(key)) {
activeKeys[key] = true;
updateKeyVisuals();
}
});
window.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
if (activeKeys.hasOwnProperty(key)) {
activeKeys[key] = false;
updateKeyVisuals();
}
});
function updateKeyVisuals() {
document.getElementById('keyW').classList.toggle('active', activeKeys.w);
document.getElementById('keyA').classList.toggle('active', activeKeys.a);
document.getElementById('keyS').classList.toggle('active', activeKeys.s);
document.getElementById('keyD').classList.toggle('active', activeKeys.d);
// Calculate wheel rotation based on keys
let targetRotation = 0;
if (activeKeys.a) targetRotation = -45;
if (activeKeys.d) targetRotation = 45;
steeringWheel.style.transform = `rotate(${targetRotation}deg)`;
// Compute values
currentThrottle = activeKeys.w ? 1.0 : 0.0;
currentBrake = activeKeys.s ? 1.0 : 0.0;
currentSteer = 0.0;
if (activeKeys.a) currentSteer = -1.0;
if (activeKeys.d) currentSteer = 1.0;
// Log control changes to UI console
log(`Controls updated: throttle=${currentThrottle}, steering=${currentSteer}, brake=${currentBrake}`, 'out');
}
connectBtn.addEventListener('click', () => {
if (pc) {
disconnectCockpit();
} else {
connectCockpit();
}
});
async function connectCockpit() {
const driverId = driverIdInput.value.trim().toUpperCase();
if (!driverId) {
alert('Please enter a valid Driver ID');
return;
}
log(`Initializing WebRTC Connection for Driver: ${driverId}...`);
updateStatus('connecting', 'Connecting...');
connectBtn.disabled = true;
try {
// 1. Create PeerConnection
pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// 2. Create Data Channels (Client Initiated)
dataChannel = pc.createDataChannel('data', { ordered: true });
videoChannel = pc.createDataChannel('video', { ordered: false });
setupDataChannel(dataChannel);
setupVideoChannel(videoChannel);
// 3. Create SDP Offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 4. Wait for ICE gathering to complete (Vanilla ICE)
log('Gathering ICE Candidates...');
await new Promise((resolve) => {
if (pc.iceGatheringState === 'complete') {
resolve();
} else {
function checkState() {
if (pc.iceGatheringState === 'complete') {
pc.removeEventListener('icegatheringstatechange', checkState);
resolve();
}
}
pc.addEventListener('icegatheringstatechange', checkState);
}
});
// 5. Post SDP Offer to Server
const signalingUrl = `${window.location.protocol}//localhost:8080/api/webrtc/connect`;
log(`Sending SDP Offer to ${signalingUrl}...`);
const response = await fetch(signalingUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sdp: pc.localDescription.sdp,
type: 'offer',
driver_id: driverId
})
});
if (!response.ok) {
throw new Error(await response.text());
}
const answer = await response.json();
log('SDP Answer received from server. Establishing WebRTC channel...');
await pc.setRemoteDescription(new RTCSessionDescription(answer));
} catch (err) {
log(`WebRTC connection failed: ${err.message}`, 'system');
updateStatus('disconnected', 'Failed');
disconnectCockpit();
} finally {
connectBtn.disabled = false;
}
}
function disconnectCockpit() {
log('Disconnecting WebRTC Cockpit...');
if (sendControlsInterval) {
clearInterval(sendControlsInterval);
sendControlsInterval = null;
}
if (dataChannel) {
dataChannel.close();
dataChannel = null;
}
if (videoChannel) {
videoChannel.close();
videoChannel = null;
}
if (pc) {
pc.close();
pc = null;
}
videoFeed.style.display = 'none';
videoPlaceholder.style.display = 'flex';
updateStatus('disconnected', 'Disconnected');
connectBtn.innerText = 'Connect';
connectBtn.classList.remove('disconnect');
// Reset Telemetry
telSpeed.innerHTML = '0.00 <span style="font-size: 0.8rem; font-weight: normal;">m/s</span>';
telPing.innerHTML = '0 <span style="font-size: 0.8rem; font-weight: normal;">ms</span>';
telDevice.innerHTML = '-';
telPos.innerHTML = '0.0, 0.0';
}
function setupDataChannel(dc) {
dc.onopen = () => {
log(`Bidirectional DataChannel ('${dc.label}') is OPEN`, 'system');
updateStatus('connected', 'Connected');
connectBtn.innerText = 'Disconnect';
connectBtn.classList.add('disconnect');
// Start sending steering controls at 30Hz
sendControlsInterval = setInterval(sendControls, 33);
};
dc.onclose = () => {
log(`DataChannel ('${dc.label}') is CLOSED`, 'system');
disconnectCockpit();
};
dc.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'telemetry') {
// Update Telemetry Panel
telSpeed.innerHTML = `${msg.speed.toFixed(2)} <span style="font-size: 0.8rem; font-weight: normal;">m/s</span>`;
telDevice.innerHTML = msg.car_id || '-';
telPos.innerHTML = `${msg.x.toFixed(1)}, ${msg.y.toFixed(1)}`;
// Measure RTT
const currentMs = Date.now();
const latency = currentMs - msg.timestamp;
telPing.innerHTML = `${latency} <span style="font-size: 0.8rem; font-weight: normal;">ms</span>`;
if (pingsReceived % 5 === 0) {
log(`Telemetry RX: Speed=${msg.speed.toFixed(2)}m/s Car=${msg.car_id || '-'}`, 'in');
}
pingsReceived++;
} else if (msg.type === 'ack') {
// Log ACK periodically (every 30 frames) to verify loopback
if (pingsReceived % 30 === 0) {
log(`ACK RX from server: applied=${msg.applied}`, 'in');
}
}
} catch (err) {
log(`DataChannel Error: ${err.message}`, 'system');
}
};
}
function setupVideoChannel(dc) {
dc.binaryType = 'arraybuffer';
dc.onopen = () => {
log(`Video DataChannel ('${dc.label}') is OPEN`, 'system');
videoPlaceholder.style.display = 'none';
videoFeed.style.display = 'block';
};
dc.onclose = () => {
log(`Video DataChannel ('${dc.label}') is CLOSED`, 'system');
};
dc.onmessage = (event) => {
// Binary JPEG frames received
const blob = new Blob([event.data], { type: 'image/jpeg' });
const url = URL.createObjectURL(blob);
videoFeed.onload = () => {
URL.revokeObjectURL(url);
};
videoFeed.src = url;
};
}
function sendControls() {
if (dataChannel && dataChannel.readyState === 'open') {
const payload = {
throttle: currentThrottle,
steering: currentSteer,
brake: currentBrake,
gear: 1,
timestamp: Date.now()
};
dataChannel.send(JSON.stringify(payload));
}
}
function updateStatus(state, label) {
statusText.innerText = label;
statusDot.className = 'status-dot';
if (state === 'connected') statusDot.classList.add('connected');
if (state === 'connecting') statusDot.classList.add('connecting');
}
</script>
</body>
</html>