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, "") s.mirror(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 = "" 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 } 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 = "" 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 = "" 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 } // 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 }