mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
feat(server): integrate UDP video service and normalize database schema
- Implement UDPVideoService to stream MJPEG video frames from ESP32s via UDP and send control commands. - Normalize race database schema: - Remove redundant driver_ids array from races table (migration 011). - Move per-driver outcomes (total_time_ms, best_lap_ms, position) to race_drivers table (migration 012). - Add device_id column to lobby_drivers to link active physical devices (migration 013). - Update WebSocket handler to pass lobbySvc, driversSvc, and videoSvc for driver identity tracking, syncing in-memory driver metadata, and forwarding WS controls to physical cars. - Add CORS middleware to HTTP routes. - Regenerate Swagger documentation.
This commit is contained in:
@@ -394,9 +394,9 @@ func (r *Runner) makeFinished(idx int) races.FinishedRace {
|
||||
}
|
||||
bestLapMs := int64(20_000 + rng.Intn(8_000))
|
||||
|
||||
// Podium: top-3 by total time. The winner always finishes; the
|
||||
// remaining drivers are sorted by a per-driver total time derived
|
||||
// from the winner's best lap.
|
||||
// Per-driver total time. The winner is fastest; the rest are
|
||||
// winner's time + 2..16 seconds. Build the Results slice with
|
||||
// computed positions.
|
||||
perDriver := make([]int64, len(picked))
|
||||
for i, d := range picked {
|
||||
if d.ID == winner.ID {
|
||||
@@ -419,35 +419,35 @@ func (r *Runner) makeFinished(idx int) races.FinishedRace {
|
||||
all[j-1], all[j] = all[j], all[j-1]
|
||||
}
|
||||
}
|
||||
podium := make([]races.PodiumEntry, 0, 3)
|
||||
for i := 0; i < len(all) && i < 3; i++ {
|
||||
podium = append(podium, races.PodiumEntry{
|
||||
Position: i + 1,
|
||||
DriverID: all[i].d.ID,
|
||||
Name: all[i].d.Nickname,
|
||||
TotalTimeMs: all[i].time,
|
||||
results := make([]races.DriverResult, 0, len(all))
|
||||
for i, row := range all {
|
||||
pos := i + 1
|
||||
// Podium only for the top-3; the rest are just DNF-ish finishers
|
||||
// (they have a time and a position).
|
||||
results = append(results, races.DriverResult{
|
||||
DriverID: row.d.ID,
|
||||
TotalTimeMs: row.time,
|
||||
BestLapMs: bestLapMs,
|
||||
Position: &pos,
|
||||
})
|
||||
}
|
||||
|
||||
return races.FinishedRace{
|
||||
ID: fmt.Sprintf("seed-finished-%03d", idx+1),
|
||||
Name: fmt.Sprintf("Seed Race #%d (%s)", idx+1, track),
|
||||
TrackID: track,
|
||||
MaxCars: 4,
|
||||
Laps: laps,
|
||||
TimeLimitS: 0,
|
||||
DriverIDs: driverIDs,
|
||||
Status: "finished",
|
||||
CreatedMs: finishedMs - randDuration(rng, 5, 120).Milliseconds(),
|
||||
StartedMs: startedMs,
|
||||
FinishedMs: finishedMs,
|
||||
DurationMs: finishedMs - startedMs,
|
||||
TotalLaps: totalLaps,
|
||||
TotalDrivers: len(driverIDs),
|
||||
WinnerDriverID: winner.ID,
|
||||
WinnerName: winner.Nickname,
|
||||
BestLapMs: bestLapMs,
|
||||
Podium: podium,
|
||||
ID: fmt.Sprintf("seed-finished-%03d", idx+1),
|
||||
Name: fmt.Sprintf("Seed Race #%d (%s)", idx+1, track),
|
||||
TrackID: track,
|
||||
MaxCars: 4,
|
||||
Laps: laps,
|
||||
TimeLimitS: 0,
|
||||
DriverIDs: driverIDs,
|
||||
Status: "finished",
|
||||
CreatedMs: finishedMs - randDuration(rng, 5, 120).Milliseconds(),
|
||||
StartedMs: startedMs,
|
||||
FinishedMs: finishedMs,
|
||||
DurationMs: finishedMs - startedMs,
|
||||
TotalLaps: totalLaps,
|
||||
TotalDrivers: len(driverIDs),
|
||||
Results: results,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,6 +481,8 @@ func (r *Runner) createLive(status lobby.RaceStatus, idx int) (lobby.RaceMeta, e
|
||||
continue
|
||||
}
|
||||
r.lb.SetDriverProfile(d.ID, d.Nickname, "", d.ClanID, d.ClanTag)
|
||||
devID := i
|
||||
_ = r.lb.SelectDevice(d.ID, &devID)
|
||||
if err := r.lb.AddDriverToRace(d.ID, meta.ID); err != nil {
|
||||
// ignore: race may be at capacity
|
||||
}
|
||||
|
||||
@@ -62,6 +62,9 @@ func (s *Service) RestoreFromDB(ctx context.Context) (int, int, error) {
|
||||
// AddDriver+SetDriverProfile and accept a no-op mirror.
|
||||
_, _ = s.lobby.AddDriver(d.ID, d.Name, "")
|
||||
s.lobby.SetDriverProfile(d.ID, d.Nickname, d.AvatarURL, d.ClanID, d.ClanTag)
|
||||
if d.DeviceID != nil {
|
||||
_ = s.lobby.SelectDevice(d.ID, d.DeviceID)
|
||||
}
|
||||
}
|
||||
for _, r := range races {
|
||||
// We bypass CreateRace to avoid duplicating persistence writes
|
||||
@@ -106,6 +109,7 @@ type RaceItem struct {
|
||||
type ListResult struct {
|
||||
Items []RaceItem
|
||||
NextCursor string // empty when no more pages
|
||||
TotalCount int
|
||||
}
|
||||
|
||||
// ListRaces returns one keyset page of races matching the filter.
|
||||
@@ -186,7 +190,34 @@ func (s *Service) ListRaces(ctx context.Context, f ListFilter) (ListResult, erro
|
||||
out = out[:limit]
|
||||
}
|
||||
|
||||
res := ListResult{Items: out}
|
||||
var totalCount int
|
||||
sqlStatuses := getSQLStatuses(f.Statuses)
|
||||
if s.live != nil {
|
||||
var err error
|
||||
totalCount, err = s.pg.CountRaces(ctx, sqlStatuses, f.TrackID)
|
||||
if err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
} else {
|
||||
// Fallback: in-memory live + pg finished
|
||||
liveCount := 0
|
||||
for _, r := range s.lobby.ListRaces() {
|
||||
if statusMatches(f.Statuses, string(r.Status)) && (f.TrackID == "" || r.TrackID == f.TrackID) {
|
||||
liveCount++
|
||||
}
|
||||
}
|
||||
var finishedCount int
|
||||
if statusWantsFinished(f.Statuses) {
|
||||
var err error
|
||||
finishedCount, err = s.pg.CountRaces(ctx, []string{"finished", "cancelled"}, f.TrackID)
|
||||
if err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
}
|
||||
totalCount = liveCount + finishedCount
|
||||
}
|
||||
|
||||
res := ListResult{Items: out, TotalCount: totalCount}
|
||||
if hasMore && len(out) > 0 {
|
||||
last := out[len(out)-1]
|
||||
res.NextCursor = Cursor{
|
||||
@@ -292,6 +323,37 @@ func filterToStatusStrings(filters []StatusFilter) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func getSQLStatuses(filters []StatusFilter) []string {
|
||||
var sqlStatuses []string
|
||||
wantLive := false
|
||||
wantFinished := false
|
||||
if len(filters) == 0 {
|
||||
wantLive = true
|
||||
wantFinished = true
|
||||
} else {
|
||||
for _, f := range filters {
|
||||
if f == StatusAll {
|
||||
wantLive = true
|
||||
wantFinished = true
|
||||
break
|
||||
}
|
||||
if f.matchesLive("finished") || f == StatusFinished {
|
||||
wantFinished = true
|
||||
}
|
||||
if f.matchesLive("lobby") || f == StatusLobby || f == StatusRacing {
|
||||
wantLive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if wantLive {
|
||||
sqlStatuses = append(sqlStatuses, filterToStatusStrings(filters)...)
|
||||
}
|
||||
if wantFinished {
|
||||
sqlStatuses = append(sqlStatuses, "finished", "cancelled")
|
||||
}
|
||||
return sqlStatuses
|
||||
}
|
||||
|
||||
func (s *Service) collectFinished(ctx context.Context, filters []StatusFilter, trackID, curSource string, curMs int64, curID string, limit int) ([]RaceItem, error) {
|
||||
if !statusWantsFinished(filters) {
|
||||
return nil, nil
|
||||
@@ -488,8 +550,11 @@ func (s *Service) ListQueue(ctx context.Context, driverID string) ([]QueueEntry,
|
||||
// Finished-race snapshot hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SnapshotFinished is the input to the "persist on finish" hook. Race is
|
||||
// a copy of the in-memory meta. Result is filled by the engine.
|
||||
// SnapshotFinished is the input to the "persist on finish" hook.
|
||||
// The engine fills Meta, FinishedMs, TotalLaps and either:
|
||||
// * Results (full per-driver totals / positions), or
|
||||
// * the legacy WinnerDriverID + BestLapMs fallback (used by the
|
||||
// seeder and any caller that doesn't track per-driver times).
|
||||
type SnapshotFinished struct {
|
||||
Meta lobby.RaceMeta
|
||||
FinishedMs int64
|
||||
@@ -497,6 +562,7 @@ type SnapshotFinished struct {
|
||||
WinnerDriverID string
|
||||
WinnerName string
|
||||
BestLapMs int64
|
||||
Results []DriverResult
|
||||
}
|
||||
|
||||
// PersistFinished upserts the finished race into Postgres.
|
||||
@@ -506,24 +572,37 @@ func (s *Service) PersistFinished(ctx context.Context, sf SnapshotFinished) erro
|
||||
if finishedMs == 0 {
|
||||
finishedMs = s.now().UnixMilli()
|
||||
}
|
||||
// Build the per-driver results from the in-memory race meta and
|
||||
// the snapshot. In PoC we don't have per-driver totals from the
|
||||
// engine yet, so we use the snapshot's WinnerDriverID as a
|
||||
// single-row fallback when Results is empty. The full per-driver
|
||||
// totals will be supplied by the engine in a future iteration.
|
||||
results := sf.Results
|
||||
if len(results) == 0 && sf.WinnerDriverID != "" {
|
||||
pos := 1
|
||||
results = []DriverResult{{
|
||||
DriverID: sf.WinnerDriverID,
|
||||
TotalTimeMs: finishedMs - startedMs,
|
||||
BestLapMs: sf.BestLapMs,
|
||||
Position: &pos,
|
||||
}}
|
||||
}
|
||||
r := FinishedRace{
|
||||
ID: sf.Meta.ID,
|
||||
Name: sf.Meta.Name,
|
||||
TrackID: sf.Meta.TrackID,
|
||||
MaxCars: sf.Meta.MaxCars,
|
||||
Laps: sf.Meta.Laps,
|
||||
TimeLimitS: sf.Meta.TimeLimitS,
|
||||
DriverIDs: append([]string(nil), sf.Meta.DriverIDs...),
|
||||
Status: "finished",
|
||||
CreatedMs: sf.Meta.CreatedMs,
|
||||
StartedMs: startedMs,
|
||||
FinishedMs: finishedMs,
|
||||
DurationMs: finishedMs - startedMs,
|
||||
TotalLaps: sf.TotalLaps,
|
||||
TotalDrivers: len(sf.Meta.DriverIDs),
|
||||
WinnerDriverID: sf.WinnerDriverID,
|
||||
WinnerName: sf.WinnerName,
|
||||
BestLapMs: sf.BestLapMs,
|
||||
ID: sf.Meta.ID,
|
||||
Name: sf.Meta.Name,
|
||||
TrackID: sf.Meta.TrackID,
|
||||
MaxCars: sf.Meta.MaxCars,
|
||||
Laps: sf.Meta.Laps,
|
||||
TimeLimitS: sf.Meta.TimeLimitS,
|
||||
DriverIDs: append([]string(nil), sf.Meta.DriverIDs...),
|
||||
Status: "finished",
|
||||
CreatedMs: sf.Meta.CreatedMs,
|
||||
StartedMs: startedMs,
|
||||
FinishedMs: finishedMs,
|
||||
DurationMs: finishedMs - startedMs,
|
||||
TotalLaps: sf.TotalLaps,
|
||||
TotalDrivers: len(sf.Meta.DriverIDs),
|
||||
Results: results,
|
||||
}
|
||||
return s.pg.InsertFinished(ctx, r)
|
||||
}
|
||||
@@ -596,6 +675,11 @@ func (s *Service) ListPlans(ctx context.Context, cur Cursor, limit int) ([]RaceP
|
||||
return s.pg.ListPlans(ctx, cur, limit)
|
||||
}
|
||||
|
||||
// CountPlans returns the total number of race plans.
|
||||
func (s *Service) CountPlans(ctx context.Context) (int, error) {
|
||||
return s.pg.CountPlans(ctx)
|
||||
}
|
||||
|
||||
// DeletePlan removes a plan by id.
|
||||
func (s *Service) DeletePlan(ctx context.Context, id string) error {
|
||||
return s.pg.DeletePlan(ctx, id)
|
||||
|
||||
+212
-62
@@ -2,9 +2,9 @@ package races
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -24,23 +24,44 @@ type PodiumEntry struct {
|
||||
TotalTimeMs int64 `json:"total_time_ms" example:"123456"`
|
||||
}
|
||||
|
||||
// DriverResult is the per-driver outcome of a race. Stored in
|
||||
// race_drivers. DriverID + TotalTimeMs are required; BestLapMs is
|
||||
// the driver's fastest lap; Position is 1..N when the driver
|
||||
// finished, or NULL when DNF/DSQ. The aggregate podium and winner
|
||||
// fields are derived from this row set on read.
|
||||
type DriverResult struct {
|
||||
DriverID string
|
||||
TotalTimeMs int64
|
||||
BestLapMs int64
|
||||
Position *int // nil for DNF
|
||||
}
|
||||
|
||||
// FinishedRace is the on-disk shape of a completed race. Mirrors the
|
||||
// lobby.RaceMeta plus outcome fields populated by the engine at finish.
|
||||
// lobby.RaceMeta plus aggregate outcome fields. Per-driver results
|
||||
// (including the podium) live in race_drivers and are read into
|
||||
// Results on demand.
|
||||
type FinishedRace struct {
|
||||
ID string
|
||||
Name string
|
||||
TrackID string
|
||||
MaxCars int
|
||||
Laps int
|
||||
TimeLimitS int
|
||||
DriverIDs []string
|
||||
Status string // "finished" | "cancelled"
|
||||
CreatedMs int64
|
||||
StartedMs int64
|
||||
FinishedMs int64
|
||||
DurationMs int64
|
||||
TotalLaps int
|
||||
TotalDrivers int
|
||||
ID string
|
||||
Name string
|
||||
TrackID string
|
||||
MaxCars int
|
||||
Laps int
|
||||
TimeLimitS int
|
||||
DriverIDs []string
|
||||
Status string // "finished" | "cancelled"
|
||||
CreatedMs int64
|
||||
StartedMs int64
|
||||
FinishedMs int64
|
||||
DurationMs int64
|
||||
TotalLaps int
|
||||
TotalDrivers int
|
||||
|
||||
// Results is the per-driver race_drivers data, hydrated on read.
|
||||
// Includes the winner and the podium.
|
||||
Results []DriverResult
|
||||
|
||||
// Derived fields (computed in scanFinished from Results). The
|
||||
// public API exposes them so existing clients keep working.
|
||||
WinnerDriverID string
|
||||
WinnerName string
|
||||
BestLapMs int64
|
||||
@@ -126,8 +147,8 @@ func (s *PgStore) UpsertLive(ctx context.Context, r lobby.RaceMeta) error {
|
||||
_, err := s.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, podium)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10, '[]'::jsonb)
|
||||
created_ms, started_ms, finished_ms, updated_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
track_id = EXCLUDED.track_id,
|
||||
@@ -363,8 +384,8 @@ func (s *PgStore) UpsertLobbyDriver(ctx context.Context, d lobby.DriverMeta) err
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO lobby_drivers
|
||||
(driver_id, name, nickname, avatar_url, clan_id, clan_tag,
|
||||
status, current_race_id, connected_ms, last_seen_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
status, current_race_id, connected_ms, last_seen_ms, device_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (driver_id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
nickname = EXCLUDED.nickname,
|
||||
@@ -373,9 +394,10 @@ func (s *PgStore) UpsertLobbyDriver(ctx context.Context, d lobby.DriverMeta) err
|
||||
clan_tag = EXCLUDED.clan_tag,
|
||||
status = EXCLUDED.status,
|
||||
current_race_id = EXCLUDED.current_race_id,
|
||||
last_seen_ms = EXCLUDED.last_seen_ms`,
|
||||
last_seen_ms = EXCLUDED.last_seen_ms,
|
||||
device_id = EXCLUDED.device_id`,
|
||||
d.ID, d.Name, d.Nickname, d.AvatarURL, d.ClanID, d.ClanTag,
|
||||
string(d.Status), d.RaceID, d.ConnectedMs, d.LastSeenMs)
|
||||
string(d.Status), d.RaceID, d.ConnectedMs, d.LastSeenMs, d.DeviceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert lobby driver %s: %w", d.ID, err)
|
||||
}
|
||||
@@ -392,7 +414,7 @@ func (s *PgStore) DeleteLobbyDriver(ctx context.Context, driverID string) error
|
||||
func (s *PgStore) ListLobbyDrivers(ctx context.Context) ([]lobby.DriverMeta, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT driver_id, name, nickname, avatar_url, clan_id, clan_tag,
|
||||
status, current_race_id, connected_ms, last_seen_ms
|
||||
status, current_race_id, connected_ms, last_seen_ms, device_id
|
||||
FROM lobby_drivers ORDER BY last_seen_ms DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -403,7 +425,7 @@ func (s *PgStore) ListLobbyDrivers(ctx context.Context) ([]lobby.DriverMeta, err
|
||||
var d lobby.DriverMeta
|
||||
var status string
|
||||
if err := rows.Scan(&d.ID, &d.Name, &d.Nickname, &d.AvatarURL, &d.ClanID, &d.ClanTag,
|
||||
&status, &d.RaceID, &d.ConnectedMs, &d.LastSeenMs); err != nil {
|
||||
&status, &d.RaceID, &d.ConnectedMs, &d.LastSeenMs, &d.DeviceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Status = lobby.DriverStatus(status)
|
||||
@@ -416,41 +438,66 @@ func (s *PgStore) ListLobbyDrivers(ctx context.Context) ([]lobby.DriverMeta, err
|
||||
// finished_races
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// InsertFinished upserts a finished race row.
|
||||
// InsertFinished upserts a finished race row and replaces its
|
||||
// race_drivers entries in a single transaction. Per-driver outcome
|
||||
// (total_time_ms, best_lap_ms, position) lives on race_drivers;
|
||||
// the race row itself carries only metadata.
|
||||
func (s *PgStore) InsertFinished(ctx context.Context, r FinishedRace) error {
|
||||
podium := r.Podium
|
||||
if podium == nil {
|
||||
podium = []PodiumEntry{}
|
||||
}
|
||||
podiumJSON, err := json.Marshal(podium)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal podium: %w", err)
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO races
|
||||
(id, name, track_id, max_cars, laps, time_limit_s,
|
||||
driver_ids, status, created_ms, started_ms, finished_ms, duration_ms,
|
||||
total_laps, total_drivers, winner_driver_id, winner_name, best_lap_ms,
|
||||
podium)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
|
||||
status, created_ms, started_ms, finished_ms, duration_ms,
|
||||
total_laps, total_drivers)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
status = EXCLUDED.status,
|
||||
finished_ms = EXCLUDED.finished_ms,
|
||||
duration_ms = EXCLUDED.duration_ms,
|
||||
total_laps = EXCLUDED.total_laps,
|
||||
total_drivers = EXCLUDED.total_drivers,
|
||||
winner_driver_id = EXCLUDED.winner_driver_id,
|
||||
winner_name = EXCLUDED.winner_name,
|
||||
best_lap_ms = EXCLUDED.best_lap_ms,
|
||||
podium = EXCLUDED.podium`,
|
||||
total_drivers = EXCLUDED.total_drivers`,
|
||||
r.ID, r.Name, r.TrackID, r.MaxCars, r.Laps, r.TimeLimitS,
|
||||
r.DriverIDs, r.Status, r.CreatedMs, r.StartedMs, r.FinishedMs, r.DurationMs,
|
||||
r.TotalLaps, r.TotalDrivers, r.WinnerDriverID, r.WinnerName, r.BestLapMs,
|
||||
podiumJSON)
|
||||
r.Status, r.CreatedMs, r.StartedMs, r.FinishedMs, r.DurationMs,
|
||||
r.TotalLaps, r.TotalDrivers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert finished race %s: %w", r.ID, err)
|
||||
}
|
||||
// Replace race_drivers with the provided list atomically.
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM race_drivers WHERE race_id = $1`, r.ID); err != nil {
|
||||
return fmt.Errorf("clear race_drivers for %s: %w", r.ID, err)
|
||||
}
|
||||
// Build a map from driver_id -> DriverResult for lookup.
|
||||
resByDriver := make(map[string]DriverResult, len(r.Results))
|
||||
for _, dr := range r.Results {
|
||||
resByDriver[dr.DriverID] = dr
|
||||
}
|
||||
for i, did := range r.DriverIDs {
|
||||
dr, ok := resByDriver[did]
|
||||
_ = ok // missing result: total_time_ms=0, best_lap_ms=0, position=NULL
|
||||
var totalTime, bestLap int64
|
||||
var pos *int
|
||||
if ok {
|
||||
totalTime = dr.TotalTimeMs
|
||||
bestLap = dr.BestLapMs
|
||||
pos = dr.Position
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO race_drivers
|
||||
(race_id, driver_id, slot, joined_ms,
|
||||
total_time_ms, best_lap_ms, position)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
r.ID, did, i, r.CreatedMs, totalTime, bestLap, pos); err != nil {
|
||||
return fmt.Errorf("insert race_drivers %s: %w", r.ID, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit race_drivers: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -474,9 +521,8 @@ func (s *PgStore) ListFinished(ctx context.Context, trackID string, cur Cursor,
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
q := `SELECT id, name, track_id, max_cars, laps, time_limit_s,
|
||||
driver_ids, status, created_ms, started_ms, finished_ms, duration_ms,
|
||||
total_laps, total_drivers, winner_driver_id, winner_name, best_lap_ms,
|
||||
podium
|
||||
status, created_ms, started_ms, finished_ms, duration_ms,
|
||||
total_laps, total_drivers
|
||||
FROM races
|
||||
WHERE ` + joinAnd(conds) + `
|
||||
ORDER BY finished_ms DESC, id DESC
|
||||
@@ -486,36 +532,130 @@ func (s *PgStore) ListFinished(ctx context.Context, trackID string, cur Cursor,
|
||||
return nil, fmt.Errorf("list finished: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanFinished(rows)
|
||||
return s.scanFinished(ctx, rows)
|
||||
}
|
||||
|
||||
func scanFinished(rows pgx.Rows) ([]FinishedRace, error) {
|
||||
// CountRaces returns the total number of races matching the status filter and trackID.
|
||||
func (s *PgStore) CountRaces(ctx context.Context, statuses []string, trackID string) (int, error) {
|
||||
args := []any{}
|
||||
conds := []string{"1=1"}
|
||||
if len(statuses) > 0 {
|
||||
placeholders := make([]string, len(statuses))
|
||||
for i, st := range statuses {
|
||||
args = append(args, st)
|
||||
placeholders[i] = fmt.Sprintf("$%d", len(args))
|
||||
}
|
||||
conds = append(conds, fmt.Sprintf("status IN (%s)", strings.Join(placeholders, ",")))
|
||||
}
|
||||
if trackID != "" {
|
||||
args = append(args, trackID)
|
||||
conds = append(conds, fmt.Sprintf("track_id = $%d", len(args)))
|
||||
}
|
||||
q := `SELECT COUNT(*) FROM races WHERE ` + joinAnd(conds)
|
||||
var count int
|
||||
err := s.pool.QueryRow(ctx, q, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count races: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// scanFinished reads the rows from the SELECT above, then hydrates
|
||||
// DriverIDs and the per-driver Results from race_drivers. The
|
||||
// Winner* and Podium fields are derived from Results.
|
||||
func (s *PgStore) scanFinished(ctx context.Context, rows pgx.Rows) ([]FinishedRace, error) {
|
||||
out := make([]FinishedRace, 0)
|
||||
for rows.Next() {
|
||||
var r FinishedRace
|
||||
var podiumJSON []byte
|
||||
if err := rows.Scan(
|
||||
&r.ID, &r.Name, &r.TrackID,
|
||||
&r.MaxCars, &r.Laps, &r.TimeLimitS, &r.DriverIDs, &r.Status,
|
||||
&r.MaxCars, &r.Laps, &r.TimeLimitS, &r.Status,
|
||||
&r.CreatedMs, &r.StartedMs, &r.FinishedMs, &r.DurationMs,
|
||||
&r.TotalLaps, &r.TotalDrivers, &r.WinnerDriverID, &r.WinnerName, &r.BestLapMs,
|
||||
&podiumJSON,
|
||||
&r.TotalLaps, &r.TotalDrivers,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(podiumJSON) > 0 {
|
||||
if err := json.Unmarshal(podiumJSON, &r.Podium); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal podium: %w", err)
|
||||
}
|
||||
}
|
||||
if r.Podium == nil {
|
||||
r.Podium = []PodiumEntry{}
|
||||
if err := s.hydrateFinished(ctx, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// hydrateFinished pulls race_drivers for the given race into Results
|
||||
// and derives the Winner* and Podium fields.
|
||||
func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT driver_id, total_time_ms, best_lap_ms, position
|
||||
FROM race_drivers
|
||||
WHERE race_id = $1
|
||||
ORDER BY slot ASC, joined_ms ASC`, r.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hydrate race_drivers %s: %w", r.ID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
r.Results = make([]DriverResult, 0)
|
||||
r.DriverIDs = make([]string, 0)
|
||||
r.Podium = make([]PodiumEntry, 0)
|
||||
for rows.Next() {
|
||||
var dr DriverResult
|
||||
var pos *int
|
||||
if err := rows.Scan(&dr.DriverID, &dr.TotalTimeMs, &dr.BestLapMs, &pos); err != nil {
|
||||
return err
|
||||
}
|
||||
dr.Position = pos
|
||||
r.Results = append(r.Results, dr)
|
||||
r.DriverIDs = append(r.DriverIDs, dr.DriverID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Derive winner (lowest total_time_ms among rows with time > 0).
|
||||
var bestTotal int64 = -1
|
||||
for _, dr := range r.Results {
|
||||
if dr.TotalTimeMs <= 0 {
|
||||
continue
|
||||
}
|
||||
if bestTotal == -1 || dr.TotalTimeMs < bestTotal {
|
||||
bestTotal = dr.TotalTimeMs
|
||||
r.WinnerDriverID = dr.DriverID
|
||||
r.WinnerName = dr.DriverID
|
||||
}
|
||||
if dr.BestLapMs > 0 && (r.BestLapMs == 0 || dr.BestLapMs < r.BestLapMs) {
|
||||
r.BestLapMs = dr.BestLapMs
|
||||
}
|
||||
}
|
||||
// Derive podium: top-3 drivers by total_time_ms (> 0) ASC.
|
||||
type row struct {
|
||||
id string
|
||||
t int64
|
||||
best int64
|
||||
}
|
||||
var sorted []row
|
||||
for _, dr := range r.Results {
|
||||
if dr.TotalTimeMs <= 0 {
|
||||
continue
|
||||
}
|
||||
sorted = append(sorted, row{dr.DriverID, dr.TotalTimeMs, dr.BestLapMs})
|
||||
}
|
||||
for i := 1; i < len(sorted); i++ {
|
||||
for j := i; j > 0 && sorted[j-1].t > sorted[j].t; j-- {
|
||||
sorted[j-1], sorted[j] = sorted[j], sorted[j-1]
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(sorted) && i < 3; i++ {
|
||||
r.Podium = append(r.Podium, PodiumEntry{
|
||||
Position: i + 1,
|
||||
DriverID: sorted[i].id,
|
||||
Name: sorted[i].id,
|
||||
TotalTimeMs: sorted[i].t,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func joinAnd(parts []string) string {
|
||||
out := ""
|
||||
for i, p := range parts {
|
||||
@@ -624,6 +764,16 @@ func scanPlans(rows pgx.Rows) ([]RacePlan, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountPlans returns the total number of race plans.
|
||||
func (s *PgStore) CountPlans(ctx context.Context) (int, error) {
|
||||
var count int
|
||||
err := s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM race_plans").Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count plans: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// DeletePlan removes a plan by id.
|
||||
func (s *PgStore) DeletePlan(ctx context.Context, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM race_plans WHERE id = $1`, id)
|
||||
|
||||
Reference in New Issue
Block a user