package seed import ( "context" "fmt" "math/rand" "time" "github.com/x0gp/server/internal/clans" "github.com/x0gp/server/internal/drivers" "github.com/x0gp/server/internal/lobby" "github.com/x0gp/server/internal/races" ) // DefaultCounts are the volumes produced by Run. type DefaultCounts struct { Finished int // default 30 Live int // default 5 Plans int // default 5 } // DefaultSeed is the deterministic seed. const DefaultSeed = 0xA1B2C3D4 // DefaultNow is overridable for tests. var DefaultNow = time.Now // Track catalogue used by the seeder. Loaded from the tracks table at // run time. If no tracks exist, the seeder falls back to ["default"]. var fallbackTracks = []string{"default"} var defaultDriverSeeds = []struct { nick string name string }{ {"ACE", "Alice"}, {"BOB", "Bob"}, {"CAR", "Carol"}, {"DAV", "Dave"}, {"EVE", "Eve"}, {"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. var defaultClanSeeds = []struct { tag string name string }{ {"ACE", "Ace Racing"}, {"RND", "Rounders"}, {"SPD", "Speed Demons"}, } // Options configures Run. type Options struct { Counts DefaultCounts Seed int64 Reset bool Now time.Time } // Summary is the result of Run. type Summary struct { FinishedInserted int LiveCreated int PlansInserted int QueueInserted int Duration time.Duration } // Runner is a self-contained seeder wired to the package's pg store and // lobby. Hold one per process; safe to call Run multiple times. type Runner struct { pg *races.PgStore lb *lobby.Service live *races.LiveStore clansPg *clans.PgStore driversPg *drivers.PgStore opt Options rng *rand.Rand now time.Time // Filled by loadInputs. trackIDs []string driverIDs []string // driver ids in the order they exist after seed driverInfo []seedDriver // id, nick, name, clanID, clanTag clanByID map[string]string // clan id -> tag (for fast profile updates) } type seedDriver struct { ID string Nickname string Name string ClanID string ClanTag string } // NewRunner constructs a Runner. func NewRunner(pg *races.PgStore, lb *lobby.Service, live *races.LiveStore, clansPg *clans.PgStore, driversPg *drivers.PgStore) *Runner { return &Runner{pg: pg, lb: lb, live: live, clansPg: clansPg, driversPg: driversPg} } // Run executes the seed against the supplied services. // // Order of operations: // // 1. (optional) reset: TRUNCATE finished_races, race_plans, race_queue; // remove all live races from the lobby; remove the auto-seeded drivers // from the lobby. // 2. Insert N finished races into Postgres. These do NOT touch the lobby. // 3. Create M live races in the lobby (mix of statuses). // 4. Insert K race plans into Postgres. The scheduler will materialise // them on its next tick (start_at_ms is in the future). // 5. Enqueue the first two drivers for the next 2 live races. func (r *Runner) Run(ctx context.Context, opt Options) (Summary, error) { if opt.Seed == 0 { opt.Seed = DefaultSeed } if opt.Now.IsZero() { opt.Now = DefaultNow() } if opt.Counts.Finished == 0 { opt.Counts.Finished = 30 } if opt.Counts.Live == 0 { opt.Counts.Live = 5 } if opt.Counts.Plans == 0 { opt.Counts.Plans = 5 } start := time.Now() var s Summary if opt.Reset { if err := r.resetAll(ctx); err != nil { return s, fmt.Errorf("reset: %w", err) } } r.rng = rand.New(rand.NewSource(opt.Seed)) r.now = opt.Now // 0. Seed drivers and clans (idempotent). Then load real track ids // from the catalog so race_plans / finished_races only reference // tracks that actually exist in the DB. if err := r.seedDriversAndClans(ctx); err != nil { return s, fmt.Errorf("seed drivers/clans: %w", err) } if err := r.loadTracks(ctx); err != nil { return s, fmt.Errorf("load tracks: %w", err) } if err := r.loadDrivers(ctx); err != nil { return s, fmt.Errorf("load drivers: %w", err) } // 1. Finished races. for i := 0; i < opt.Counts.Finished; i++ { row := r.makeFinished(i) if err := r.pg.InsertFinished(ctx, row); err != nil { return s, fmt.Errorf("insert finished %d: %w", i, err) } s.FinishedInserted++ } // 2. Live races in the lobby. statuses := []lobby.RaceStatus{ lobby.RaceStatusLobby, lobby.RaceStatusLobby, lobby.RaceStatusCountdown, lobby.RaceStatusRacing, lobby.RaceStatusLobby, } for i := 0; i < opt.Counts.Live && i < len(statuses); i++ { meta, err := r.createLive(statuses[i], i) if err != nil { return s, fmt.Errorf("create live %d: %w", i, err) } if statuses[i] != lobby.RaceStatusLobby { if err := r.lb.SetRaceStatus(meta.ID, statuses[i]); err != nil { return s, fmt.Errorf("set status %s: %w", meta.ID, err) } // Flush the new status synchronously so the DB row is in // sync with in-memory (mirror goroutines may not have run // yet). if r.live != nil { flushCtx, flushCancel := context.WithTimeout(ctx, 2*time.Second) cur, err := r.lb.GetRace(meta.ID) if err == nil { _ = r.live.SetRaceStatus(flushCtx, cur.ID, cur.Status, cur.StartedMs) } flushCancel() } } s.LiveCreated++ // First two live races: enqueue first two drivers on this race. if i < 2 { for _, d := range r.driverInfo[:2] { if _, err := r.pg.Enqueue(ctx, d.ID, meta.ID, ""); err != nil { return s, fmt.Errorf("enqueue %s: %w", d.ID, err) } s.QueueInserted++ } } } // 3. Race plans. Mix of one-shot and recurring. planShapes := []planShape{ {intervalS: 0, offsetS: 5 * 60}, // one-shot in 5 min {intervalS: 60, offsetS: 30 * 60}, // every minute, start in 30 min {intervalS: 3600, count: 24, offsetS: 60 * 60}, // hourly x24, start in 1h {intervalS: 0, offsetS: 6 * 3600}, // one-shot in 6h {intervalS: 86400, offsetS: 24 * 3600}, // daily, start tomorrow } for i := 0; i < opt.Counts.Plans && i < len(planShapes); i++ { ps := planShapes[i] p := r.makePlan(i, ps) if err := r.pg.CreatePlan(ctx, p); err != nil { return s, fmt.Errorf("create plan %d: %w", i, err) } 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 offsetS int } // resetAll wipes the seed-managed rows in the unified races table // (only finished | cancelled; live rows are removed via lobby // 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 race_queue`); err != nil { return err } 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, `DELETE FROM lobby_drivers`); err != nil { return err } return nil } // seedDriversAndClans inserts the default driver and clan set if the // tables are empty. Idempotent: existing rows are left alone. func (r *Runner) seedDriversAndClans(ctx context.Context) error { clanCount, err := r.clansPg.Count(ctx) if err != nil { return fmt.Errorf("count clans: %w", err) } if clanCount == 0 { now := time.Now().UnixMilli() for i, cs := range defaultClanSeeds { c := clans.Clan{ ID: fmt.Sprintf("clan-seed-%03d", i+1), Tag: cs.tag, Name: cs.name, AvatarURL: fmt.Sprintf("https://cdn.example.com/clans/%s.png", cs.tag), CreatedMs: now, UpdatedMs: now, } if err := r.clansPg.Create(ctx, c); err != nil { return fmt.Errorf("insert clan %s: %w", cs.tag, err) } } } drvCount, err := r.driversPg.Count(ctx) if err != nil { return fmt.Errorf("count drivers: %w", err) } if drvCount == 0 { now := time.Now().UnixMilli() // Round-robin: each driver assigned to one of the seeded clans. clanIDs := make([]string, 0, len(defaultClanSeeds)) for i := range defaultClanSeeds { clanIDs = append(clanIDs, fmt.Sprintf("clan-seed-%03d", i+1)) } for i, ds := range defaultDriverSeeds { d := drivers.Driver{ ID: fmt.Sprintf("driver-seed-%03d", i+1), Nickname: ds.nick, Name: ds.name, AvatarURL: fmt.Sprintf("https://cdn.example.com/drivers/%s.png", ds.nick), ClanID: clanIDs[i%len(clanIDs)], CreatedMs: now, UpdatedMs: now, } if err := r.driversPg.Create(ctx, d); err != nil { return fmt.Errorf("insert driver %s: %w", ds.nick, err) } } } return nil } // loadTracks fills r.trackIDs from the tracks table. Falls back to // fallbackTracks if the table is empty. func (r *Runner) loadTracks(ctx context.Context) error { ids, err := r.pg.ListTrackIDs(ctx) if err != nil { return err } if len(ids) == 0 { ids = fallbackTracks } r.trackIDs = ids return nil } // loadDrivers fills r.driverIDs and r.driverInfo from the drivers table // in the DB, then propagates the profile (nickname/avatar/clan) to the // in-memory lobby drivers. func (r *Runner) loadDrivers(ctx context.Context) error { // Pull all drivers, ordered by nickname. clanByID := make(map[string]string) clansList, err := r.clansPg.List(ctx, 200, 0) if err != nil { return fmt.Errorf("list clans: %w", err) } for _, c := range clansList { clanByID[c.ID] = c.Tag } r.clanByID = clanByID drvList, err := r.driversPg.List(ctx, 200, 0, "") if err != nil { return fmt.Errorf("list drivers: %w", err) } r.driverIDs = r.driverIDs[:0] r.driverInfo = r.driverInfo[:0] for _, d := range drvList { r.driverIDs = append(r.driverIDs, d.ID) r.driverInfo = append(r.driverInfo, seedDriver{ ID: d.ID, Nickname: d.Nickname, Name: d.Name, ClanID: d.ClanID, ClanTag: clanByID[d.ClanID], }) } return nil } // --------------------------------------------------------------------------- // Generators // --------------------------------------------------------------------------- func (r *Runner) makeFinished(idx int) races.FinishedRace { rng := r.rng now := r.now // Spread across the last 30 days. back := time.Duration(30*24-rng.Intn(30*24-1)) * time.Hour finishedMs := now.Add(-back).UnixMilli() startedMs := finishedMs - randDuration(rng, 90, 600).Milliseconds() track := r.trackIDs[rng.Intn(len(r.trackIDs))] laps := trackLaps(track) // Pick 2..4 random drivers from the seeded set. shuffled := append([]seedDriver(nil), r.driverInfo...) rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) n := 2 + rng.Intn(3) // 2..4 drivers if n > len(shuffled) { n = len(shuffled) } picked := shuffled[:n] driverIDs := make([]string, n) for i, d := range picked { driverIDs[i] = d.ID } winner := picked[rng.Intn(len(picked))] totalLaps := laps if rng.Intn(5) == 0 { // ~20% DNF/partial. totalLaps = rng.Intn(laps) } bestLapMs := int64(20_000 + rng.Intn(8_000)) // 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 { perDriver[i] = int64(totalLaps) * bestLapMs } else { perDriver[i] = int64(totalLaps)*bestLapMs + int64(2+rng.Intn(15))*1000 } } type dp struct { d seedDriver time int64 } all := make([]dp, len(picked)) for i := range picked { all[i] = dp{picked[i], perDriver[i]} } // Insertion sort by time ascending. for i := 1; i < len(all); i++ { for j := i; j > 0 && all[j-1].time > all[j].time; j-- { all[j-1], all[j] = all[j], all[j-1] } } 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), Results: results, } } func (r *Runner) createLive(status lobby.RaceStatus, idx int) (lobby.RaceMeta, error) { rng := r.rng track := r.trackIDs[rng.Intn(len(r.trackIDs))] meta, err := r.lb.CreateRace(lobby.CreateRaceOptions{ Name: fmt.Sprintf("Live %s #%d", track, idx+1), TrackID: track, MaxCars: 4, Laps: trackLaps(track), TimeLimitS: 0, }) if err != nil { return lobby.RaceMeta{}, err } // Add 1..3 random drivers into the race. We re-add them to refresh // any prior lobby state, then attach the profile (nickname/avatar/ // clan) so the WS broadcast carries rich metadata. shuffled := append([]seedDriver(nil), r.driverInfo...) rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) n := 1 + rng.Intn(3) if n > len(shuffled) { n = len(shuffled) } for i := 0; i < n; i++ { d := shuffled[i] r.lb.RemoveDriver(d.ID) if _, err := r.lb.AddDriver(d.ID, d.Name, ""); err != nil { 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 } } // Synchronously flush the race to the DB so the persistence mirror // (which uses detached goroutines) doesn't race with subsequent // status changes. The mirror calls in lobby.Service are still // fired; this upsert is a guarantee, not a replacement. if r.live != nil { flushCtx, flushCancel := context.WithTimeout(context.Background(), 2*time.Second) defer flushCancel() // Re-fetch the race from the in-memory lobby to get the latest // driver list and started_ms. cur, err := r.lb.GetRace(meta.ID) if err == nil { _ = r.live.UpsertRace(flushCtx, cur) for slot, did := range cur.DriverIDs { _ = r.live.AddDriver(flushCtx, cur.ID, did, slot) } } } _ = status return meta, nil } func (r *Runner) makePlan(idx int, ps planShape) races.RacePlan { rng := r.rng now := r.now track := r.trackIDs[rng.Intn(len(r.trackIDs))] startAt := now.Add(time.Duration(ps.offsetS) * time.Second).UnixMilli() return races.RacePlan{ ID: fmt.Sprintf("seed-plan-%03d", idx+1), Name: fmt.Sprintf("Seed Plan #%d (%s)", idx+1, track), TrackID: track, MaxCars: 4, Laps: trackLaps(track), TimeLimitS: 0, StartAtMs: startAt, IntervalS: ps.intervalS, Count: ps.count, Enabled: true, NextFireMs: startAt, } } // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- func trackLaps(track string) int { switch track { case "nordschleife": return 3 case "monza": return 12 case "spa": return 10 case "silverstone": return 8 case "suzuka": return 9 default: return 5 } } func randDuration(rng *rand.Rand, loSec, hiSec int) time.Duration { return time.Duration(loSec+rng.Intn(hiSec-loSec+1)) * time.Second } func removeFrom(in []string, v string) []string { out := make([]string, 0, len(in)-1) for _, x := range in { if x != v { out = append(out, x) } } return out }