mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
- 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.
548 lines
13 KiB
Go
548 lines
13 KiB
Go
package lobby
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// Errors returned by the lobby service.
|
|
var (
|
|
ErrRaceNotFound = errors.New("race not found")
|
|
ErrRaceFull = errors.New("race is full")
|
|
ErrRaceNotOpen = errors.New("race is not open for joining")
|
|
ErrRaceAlreadyExists = errors.New("race already exists")
|
|
ErrDriverNotFound = errors.New("driver not found")
|
|
ErrInvalidInput = errors.New("invalid input")
|
|
)
|
|
|
|
// Options for CreateRace.
|
|
type CreateRaceOptions struct {
|
|
ID string // optional; auto-generated if empty
|
|
Name string // required
|
|
TrackID string // optional, defaults to "default"
|
|
MaxCars int // required, 1..8
|
|
Laps int // 0 = time-based
|
|
TimeLimitS int // 0 = lap-based
|
|
}
|
|
|
|
// DefaultMaxCars is the fallback when CreateRaceOptions doesn't specify.
|
|
const DefaultMaxCars = 4
|
|
|
|
// raceCounter assigns sequential IDs when caller doesn't provide one.
|
|
var raceCounter seqCounter
|
|
|
|
// CreateRace registers a new race in the lobby.
|
|
//
|
|
// Rules:
|
|
// - race name is required (non-empty)
|
|
// - max_cars must be in [1, 8]
|
|
// - laps and time_limit_s can't both be 0
|
|
//
|
|
// Races are owned by a physical track; there is no host driver. Drivers
|
|
// join via AddDriverToRace.
|
|
func (s *Service) CreateRace(opts CreateRaceOptions) (RaceMeta, error) {
|
|
name := strings.TrimSpace(opts.Name)
|
|
if name == "" {
|
|
return RaceMeta{}, fmt.Errorf("%w: name required", ErrInvalidInput)
|
|
}
|
|
max := opts.MaxCars
|
|
if max == 0 {
|
|
max = DefaultMaxCars
|
|
}
|
|
if max < 1 || max > 8 {
|
|
return RaceMeta{}, fmt.Errorf("%w: max_cars must be 1..8", ErrInvalidInput)
|
|
}
|
|
if opts.Laps == 0 && opts.TimeLimitS == 0 {
|
|
return RaceMeta{}, fmt.Errorf("%w: laps or time_limit_s required", ErrInvalidInput)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
id := opts.ID
|
|
if id == "" {
|
|
id = fmt.Sprintf("race-%d-%d", time.Now().Unix(), raceCounter.v.Add(1))
|
|
} else if _, exists := s.races[id]; exists {
|
|
s.mu.Unlock()
|
|
return RaceMeta{}, ErrRaceAlreadyExists
|
|
}
|
|
|
|
track := opts.TrackID
|
|
if track == "" {
|
|
track = "default"
|
|
}
|
|
|
|
meta := RaceMeta{
|
|
ID: id,
|
|
Name: name,
|
|
TrackID: track,
|
|
MaxCars: max,
|
|
Laps: opts.Laps,
|
|
TimeLimitS: opts.TimeLimitS,
|
|
DriverIDs: []string{},
|
|
Status: RaceStatusLobby,
|
|
CreatedMs: time.Now().UnixMilli(),
|
|
}
|
|
|
|
s.races[id] = &meta
|
|
hook := s.onRaceCreated
|
|
s.mu.Unlock()
|
|
|
|
if hook != nil {
|
|
hook(meta)
|
|
}
|
|
s.bump("race_created", id, "")
|
|
// Synchronous mirror so the race row is in Postgres before any
|
|
// subsequent AddDriver mirrors try to attach drivers (FK).
|
|
s.mirrorSync(func(ctx context.Context, p Persistence) error { return p.UpsertRace(ctx, meta) })
|
|
return meta, nil
|
|
}
|
|
|
|
// AddRace inserts a race into the in-memory lobby without triggering a
|
|
// persistence write. Used by RestoreFromDB where the row is already
|
|
// in Postgres. Bumps the lobby version so subscribers re-sync.
|
|
func (s *Service) AddRace(r RaceMeta) {
|
|
if r.ID == "" {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
r2 := r
|
|
s.races[r.ID] = &r2
|
|
s.mu.Unlock()
|
|
s.bump("race_restored", r.ID, "")
|
|
}
|
|
|
|
// AddDriver registers a new driver or refreshes an existing one.
|
|
// If the driver already exists with a different name, the name is updated.
|
|
func (s *Service) AddDriver(id, name, country string) (DriverMeta, error) {
|
|
if id == "" {
|
|
return DriverMeta{}, fmt.Errorf("%w: id required", ErrInvalidInput)
|
|
}
|
|
if name == "" {
|
|
name = id
|
|
}
|
|
|
|
s.mu.Lock()
|
|
now := time.Now().UnixMilli()
|
|
broadcastKind := ""
|
|
if existing, ok := s.drivers[id]; ok {
|
|
existing.LastSeenMs = now
|
|
if name != "" {
|
|
existing.Name = name
|
|
}
|
|
if country != "" {
|
|
existing.Country = country
|
|
}
|
|
if existing.Status == DriverStatusOffline {
|
|
existing.Status = DriverStatusIdle
|
|
broadcastKind = "driver_joined"
|
|
}
|
|
out := *existing
|
|
s.mu.Unlock()
|
|
if broadcastKind != "" {
|
|
s.bump(broadcastKind, "", id)
|
|
}
|
|
return out, nil
|
|
}
|
|
d := DriverMeta{
|
|
ID: id,
|
|
Name: name,
|
|
Status: DriverStatusIdle,
|
|
ConnectedMs: now,
|
|
LastSeenMs: now,
|
|
Country: country,
|
|
AvatarHue: hashHue(id),
|
|
}
|
|
s.drivers[id] = &d
|
|
s.mu.Unlock()
|
|
s.bump("driver_joined", "", id)
|
|
s.mirror(func(ctx context.Context, p Persistence) error {
|
|
return p.UpsertDriver(ctx, d)
|
|
})
|
|
return d, nil
|
|
}
|
|
|
|
// RemoveDriver marks a driver as offline. The driver is kept in memory
|
|
// briefly to allow reconnect grace (see Snapshot filter).
|
|
func (s *Service) RemoveDriver(id string) {
|
|
var raceIDToRemove string
|
|
s.mu.Lock()
|
|
d, ok := s.drivers[id]
|
|
if !ok {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
// If still racing, treat as crash — remove from race.
|
|
if d.Status == DriverStatusRacing && d.RaceID != "" {
|
|
if race, ok := s.races[d.RaceID]; ok {
|
|
race.DriverIDs = removeString(race.DriverIDs, id)
|
|
if len(race.DriverIDs) == 0 {
|
|
delete(s.races, d.RaceID)
|
|
raceIDToRemove = d.RaceID
|
|
}
|
|
}
|
|
}
|
|
d.Status = DriverStatusOffline
|
|
d.LastSeenMs = time.Now().UnixMilli()
|
|
d.RaceID = ""
|
|
d.DeviceID = nil
|
|
hook := s.onRaceRemoved
|
|
s.mu.Unlock()
|
|
|
|
if raceIDToRemove != "" && hook != nil {
|
|
hook(raceIDToRemove)
|
|
}
|
|
s.bump("driver_left", "", id)
|
|
if raceIDToRemove != "" {
|
|
s.mirror(func(ctx context.Context, p Persistence) error {
|
|
return p.DeleteRace(context.Background(), raceIDToRemove)
|
|
})
|
|
}
|
|
s.mirror(func(ctx context.Context, p Persistence) error {
|
|
return p.DeleteDriver(ctx, id)
|
|
})
|
|
}
|
|
|
|
// Heartbeat refreshes LastSeenMs (call periodically from session).
|
|
func (s *Service) Heartbeat(id string) {
|
|
s.mu.Lock()
|
|
if d, ok := s.drivers[id]; ok {
|
|
d.LastSeenMs = time.Now().UnixMilli()
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// SetDriverProfile updates the denormalised display fields of a driver
|
|
// (nickname, avatar URL, clan). Safe to call on a missing driver (no-op).
|
|
func (s *Service) SetDriverProfile(id, nickname, avatarURL, clanID, clanTag string) {
|
|
s.mu.Lock()
|
|
var d *DriverMeta
|
|
if v, ok := s.drivers[id]; ok {
|
|
v.Nickname = nickname
|
|
v.AvatarURL = avatarURL
|
|
v.ClanID = clanID
|
|
v.ClanTag = clanTag
|
|
d = v
|
|
}
|
|
s.mu.Unlock()
|
|
if d != nil {
|
|
s.mirror(func(ctx context.Context, p Persistence) error {
|
|
return p.UpsertDriver(ctx, *d)
|
|
})
|
|
}
|
|
}
|
|
|
|
// AddDriverToRace moves a driver from idle into a race. Used by the
|
|
// client->server JoinRace flow (in addition to the race engine state).
|
|
// Idempotent: re-joining the same race is a no-op.
|
|
func (s *Service) AddDriverToRace(driverID, raceID string) error {
|
|
s.mu.Lock()
|
|
d, ok := s.drivers[driverID]
|
|
if !ok {
|
|
s.mu.Unlock()
|
|
return ErrDriverNotFound
|
|
}
|
|
if d.DeviceID == nil {
|
|
s.mu.Unlock()
|
|
return fmt.Errorf("driver %s has no device selected", driverID)
|
|
}
|
|
r, ok := s.races[raceID]
|
|
if !ok {
|
|
s.mu.Unlock()
|
|
return ErrRaceNotFound
|
|
}
|
|
if r.Status != RaceStatusLobby {
|
|
s.mu.Unlock()
|
|
return fmt.Errorf("%w: status=%s", ErrRaceNotOpen, r.Status)
|
|
}
|
|
if len(r.DriverIDs) >= r.MaxCars {
|
|
s.mu.Unlock()
|
|
return ErrRaceFull
|
|
}
|
|
if containsString(r.DriverIDs, driverID) {
|
|
d.Status = DriverStatusRacing
|
|
d.RaceID = raceID
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
r.DriverIDs = append(r.DriverIDs, driverID)
|
|
d.Status = DriverStatusRacing
|
|
d.RaceID = raceID
|
|
slot := len(r.DriverIDs) - 1
|
|
s.mu.Unlock()
|
|
s.bump("race_updated", raceID, driverID)
|
|
s.mirror(func(ctx context.Context, p Persistence) error {
|
|
return p.AddDriver(ctx, raceID, driverID, slot)
|
|
})
|
|
s.mirror(func(ctx context.Context, p Persistence) error {
|
|
return p.UpsertDriver(ctx, *d)
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// RemoveDriverFromRace returns a driver to the lobby.
|
|
func (s *Service) RemoveDriverFromRace(driverID string) {
|
|
var raceIDRemoved string
|
|
s.mu.Lock()
|
|
d, ok := s.drivers[driverID]
|
|
if !ok {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
if d.RaceID == "" {
|
|
d.Status = DriverStatusIdle
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
r, ok := s.races[d.RaceID]
|
|
if ok {
|
|
r.DriverIDs = removeString(r.DriverIDs, driverID)
|
|
if len(r.DriverIDs) == 0 {
|
|
delete(s.races, d.RaceID)
|
|
raceIDRemoved = d.RaceID
|
|
}
|
|
}
|
|
d.Status = DriverStatusIdle
|
|
d.RaceID = ""
|
|
d.DeviceID = nil
|
|
prevRaceID := d.RaceID
|
|
hook := s.onRaceRemoved
|
|
s.mu.Unlock()
|
|
|
|
if raceIDRemoved != "" && hook != nil {
|
|
hook(raceIDRemoved)
|
|
}
|
|
s.bump("race_updated", prevRaceID, driverID)
|
|
if prevRaceID != "" {
|
|
s.mirror(func(ctx context.Context, p Persistence) error {
|
|
return p.RemoveDriver(ctx, prevRaceID, driverID)
|
|
})
|
|
}
|
|
s.mirror(func(ctx context.Context, p Persistence) error {
|
|
return p.UpsertDriver(ctx, *d)
|
|
})
|
|
if raceIDRemoved != "" {
|
|
s.mirror(func(ctx context.Context, p Persistence) error {
|
|
return p.DeleteRace(ctx, raceIDRemoved)
|
|
})
|
|
}
|
|
}
|
|
|
|
// SetRaceStatus updates the status (used by Engine when race starts/ends).
|
|
func (s *Service) SetRaceStatus(raceID string, status RaceStatus) error {
|
|
s.mu.Lock()
|
|
r, ok := s.races[raceID]
|
|
if !ok {
|
|
s.mu.Unlock()
|
|
return ErrRaceNotFound
|
|
}
|
|
r.Status = status
|
|
if status == RaceStatusRacing && r.StartedMs == 0 {
|
|
r.StartedMs = time.Now().UnixMilli()
|
|
}
|
|
startedMs := r.StartedMs
|
|
var affected []DriverMeta
|
|
if status == RaceStatusFinished {
|
|
for _, did := range r.DriverIDs {
|
|
if d, ok := s.drivers[did]; ok {
|
|
d.Status = DriverStatusIdle
|
|
d.RaceID = ""
|
|
d.DeviceID = nil
|
|
affected = append(affected, *d)
|
|
}
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
s.bump("race_updated", raceID, "")
|
|
s.mirror(func(ctx context.Context, p Persistence) error {
|
|
return p.SetRaceStatus(ctx, raceID, status, startedMs)
|
|
})
|
|
for i := range affected {
|
|
d := affected[i]
|
|
s.mirror(func(ctx context.Context, p Persistence) error {
|
|
return p.UpsertDriver(ctx, d)
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteRace removes a race entirely. Drivers inside are returned to idle.
|
|
func (s *Service) DeleteRace(raceID string) error {
|
|
s.mu.Lock()
|
|
r, ok := s.races[raceID]
|
|
if !ok {
|
|
s.mu.Unlock()
|
|
return ErrRaceNotFound
|
|
}
|
|
for _, did := range r.DriverIDs {
|
|
if d, ok := s.drivers[did]; ok {
|
|
d.Status = DriverStatusIdle
|
|
d.RaceID = ""
|
|
}
|
|
}
|
|
delete(s.races, raceID)
|
|
hook := s.onRaceRemoved
|
|
s.mu.Unlock()
|
|
if hook != nil {
|
|
hook(raceID)
|
|
}
|
|
s.bump("race_removed", raceID, "")
|
|
s.mirror(func(ctx context.Context, p Persistence) error {
|
|
return p.DeleteRace(ctx, raceID)
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// QuickPlay assigns a driver to the first available race, or creates
|
|
// a new one if none exist. Returns the chosen race.
|
|
func (s *Service) QuickPlay(driverID string, defaultMaxCars int) (RaceMeta, error) {
|
|
var candidateID string
|
|
s.mu.RLock()
|
|
for _, r := range s.races {
|
|
if r.Status == RaceStatusLobby && len(r.DriverIDs) < r.MaxCars {
|
|
candidateID = r.ID
|
|
break
|
|
}
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
if candidateID != "" {
|
|
if err := s.AddDriverToRace(driverID, candidateID); err != nil {
|
|
return RaceMeta{}, err
|
|
}
|
|
return s.GetRace(candidateID)
|
|
}
|
|
driver, err := s.GetDriver(driverID)
|
|
if err != nil {
|
|
return RaceMeta{}, err
|
|
}
|
|
race, err := s.CreateRace(CreateRaceOptions{
|
|
Name: driver.Name + "'s race",
|
|
MaxCars: defaultMaxCars,
|
|
Laps: 5,
|
|
})
|
|
if err != nil {
|
|
return RaceMeta{}, err
|
|
}
|
|
if err := s.AddDriverToRace(driverID, race.ID); err != nil {
|
|
return RaceMeta{}, err
|
|
}
|
|
return s.GetRace(race.ID)
|
|
}
|
|
|
|
// GetRace returns the current meta for a race (by value, safe to mutate).
|
|
func (s *Service) GetRace(raceID string) (RaceMeta, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
r, ok := s.races[raceID]
|
|
if !ok {
|
|
return RaceMeta{}, ErrRaceNotFound
|
|
}
|
|
return *r, nil
|
|
}
|
|
|
|
// GetDriver returns the current meta for a driver.
|
|
func (s *Service) GetDriver(driverID string) (DriverMeta, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
d, ok := s.drivers[driverID]
|
|
if !ok {
|
|
return DriverMeta{}, ErrDriverNotFound
|
|
}
|
|
return *d, nil
|
|
}
|
|
|
|
// SelectDevice assigns a physical device ID (0..127) to a driver.
|
|
// Pass deviceID = nil to release the device.
|
|
// Returns an error if the device is already taken by another online driver.
|
|
func (s *Service) SelectDevice(driverID string, deviceID *int) error {
|
|
if deviceID != nil {
|
|
if *deviceID < 0 || *deviceID > 127 {
|
|
return fmt.Errorf("invalid device ID: must be 0..127")
|
|
}
|
|
}
|
|
|
|
s.mu.Lock()
|
|
d, ok := s.drivers[driverID]
|
|
if !ok {
|
|
s.mu.Unlock()
|
|
return ErrDriverNotFound
|
|
}
|
|
|
|
// Enforce that device cannot be selected if already assigned to another driver.
|
|
if deviceID != nil {
|
|
for otherID, otherD := range s.drivers {
|
|
if otherID != driverID && otherD.Status != DriverStatusOffline && otherD.DeviceID != nil && *otherD.DeviceID == *deviceID {
|
|
s.mu.Unlock()
|
|
return fmt.Errorf("device %d is already taken by driver %s", *deviceID, otherID)
|
|
}
|
|
}
|
|
}
|
|
|
|
d.DeviceID = deviceID
|
|
out := *d
|
|
s.mu.Unlock()
|
|
|
|
s.bump("driver_updated", "", driverID)
|
|
s.mirror(func(ctx context.Context, p Persistence) error {
|
|
return p.UpsertDriver(ctx, out)
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// ListRaces returns a snapshot copy of all races.
|
|
func (s *Service) ListRaces() []RaceMeta {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := make([]RaceMeta, 0, len(s.races))
|
|
for _, r := range s.races {
|
|
out = append(out, *r)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ListDrivers returns a snapshot copy of all non-offline drivers.
|
|
func (s *Service) ListDrivers() []DriverMeta {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := make([]DriverMeta, 0, len(s.drivers))
|
|
for _, d := range s.drivers {
|
|
if d.Status != DriverStatusOffline {
|
|
out = append(out, *d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// helpers -----------------------------------------------------------------
|
|
|
|
func removeString(s []string, v string) []string {
|
|
for i, x := range s {
|
|
if x == v {
|
|
return append(s[:i], s[i+1:]...)
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func containsString(s []string, v string) bool {
|
|
for _, x := range s {
|
|
if x == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hashHue returns a stable 0..360 hue for the given string.
|
|
func hashHue(s string) int {
|
|
var h uint32 = 5381
|
|
for i := 0; i < len(s); i++ {
|
|
h = h*33 ^ uint32(s[i])
|
|
}
|
|
return int(h % 360)
|
|
}
|
|
|
|
type seqCounter struct{ v atomic.Uint64 } |