mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
feat(server): initial implementation
This commit captures the full server code accumulated across
several development sessions. It includes the following layers,
applied in roughly this order during development:
1. Base infrastructure
- cmd/poc-server HTTP+WebSocket server, /health, /stats
- internal/transport wire types (Envelope + all message types)
- internal/catalog, internal/realtime, internal/stats
- internal/storage/postgres with migrations 001-004
- .env, .env.example, docker-compose, Dockerfile, scripts/
2. Swagger documentation
- go get github.com/swaggo/http-swagger
- swag init produces docs/docs.go
- /swagger/index.html UI, /swagger/doc.json spec
- annotations on health/stats/ws and catalog handlers
3. Races API
- internal/races/{store,service,keyset,types}.go
- migration 005_races.sql: finished_races, race_plans, race_queue
- GET /api/races with keyset pagination, status filter
- GET /api/races/upcoming
- POST /api/races/queue/join, GET, DELETE
- POST/GET /api/races/plans, DELETE /{id}
- lobby.RaceMeta simplification (no host_id/host_name)
4. Races seeder
- internal/races/seed with deterministic generator
- --seed-races / --reset CLI flags in main.go
- 30 finished + 5 live + 5 plans + 4 queue entries
5. Drivers and clans
- internal/drivers, internal/clans (CRUD, validation)
- migration 008_drivers_clans.sql
- /api/clans and /api/drivers endpoints
- 3-letter uppercase nickname/tag validation
- lobby.DriverMeta: Nickname, AvatarURL, ClanID, ClanTag
6. Podium
- internal/transport.RacePodiumEntry
- lobby.SetDriverProfile for in-memory metadata sync
- migration 007_podium.sql (podium JSONB column)
- seeder populates top-3 per finished race
7. Live races persistence
- migration 009_live_persistence.sql: live_races,
live_race_drivers, lobby_drivers
- internal/races/live_store.go: LiveStore with write-side
mirror for lobby.Service mutations
- Service.collectLive and Upcoming read from Postgres
- main.go RestoreFromDB rehydrates lobby on startup
8. Unify live + finished into one races table
- migration 010_unify_races.sql: rename finished_races to
races, expand status CHECK, merge live rows
- PgStore now hosts both write paths; LiveStore is a
thin facade implementing lobby.Persistence
- seeder resetAll drops only finished/cancelled rows and
race_plans / race_queue / lobby_drivers
Each layer is consistent on its own; cross-layer changes are
visible in the file history. A future refactor may split this
commit into the per-stage boundaries listed above.
This commit is contained in:
@@ -0,0 +1,562 @@
|
||||
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"}
|
||||
|
||||
// Default driver nicknames used when the drivers table is empty. Three
|
||||
// uppercase letters each (per the drivers table constraint).
|
||||
var defaultDriverSeeds = []struct {
|
||||
nick string
|
||||
name string
|
||||
}{
|
||||
{"ACE", "Alice"},
|
||||
{"BOB", "Bob"},
|
||||
{"CAR", "Carol"},
|
||||
{"DAV", "Dave"},
|
||||
{"EVE", "Eve"},
|
||||
{"FRA", "Frank"},
|
||||
{"GRA", "Grace"},
|
||||
{"HEI", "Heidi"},
|
||||
}
|
||||
|
||||
// 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++
|
||||
}
|
||||
|
||||
s.Duration = time.Since(start)
|
||||
return s, 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 races WHERE status IN ('finished','cancelled')`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx,
|
||||
`TRUNCATE TABLE race_plans, race_queue`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range r.lb.ListRaces() {
|
||||
_ = r.lb.DeleteRace(m.ID)
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx, `TRUNCATE TABLE 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))
|
||||
|
||||
// Podium: top-3 by total time. The winner always finishes; the
|
||||
// remaining drivers are sorted by a per-driver total time derived
|
||||
// from the winner's best lap.
|
||||
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]
|
||||
}
|
||||
}
|
||||
podium := make([]races.PodiumEntry, 0, 3)
|
||||
for i := 0; i < len(all) && i < 3; i++ {
|
||||
podium = append(podium, races.PodiumEntry{
|
||||
Position: i + 1,
|
||||
DriverID: all[i].d.ID,
|
||||
Name: all[i].d.Nickname,
|
||||
TotalTimeMs: all[i].time,
|
||||
})
|
||||
}
|
||||
|
||||
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),
|
||||
WinnerDriverID: winner.ID,
|
||||
WinnerName: winner.Nickname,
|
||||
BestLapMs: bestLapMs,
|
||||
Podium: podium,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user