mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
632 lines
17 KiB
Go
632 lines
17 KiB
Go
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
|
|
}
|