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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user