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,57 @@
|
||||
package races
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Cursor is a keyset cursor: opaque to the client, encoded as
|
||||
// base64url("<ms>:<id>") by Encode. Order: descending by ms, then by id.
|
||||
//
|
||||
// Empty cursor = "start from the newest".
|
||||
type Cursor struct {
|
||||
Ms int64
|
||||
ID string
|
||||
}
|
||||
|
||||
// String returns a human-readable representation (for debug logs).
|
||||
func (c Cursor) String() string {
|
||||
return fmt.Sprintf("%d:%s", c.Ms, c.ID)
|
||||
}
|
||||
|
||||
// IsZero reports whether the cursor is the zero value.
|
||||
func (c Cursor) IsZero() bool {
|
||||
return c.Ms == 0 && c.ID == ""
|
||||
}
|
||||
|
||||
// Encode renders the cursor for an HTTP query string.
|
||||
func (c Cursor) Encode() string {
|
||||
if c.IsZero() {
|
||||
return ""
|
||||
}
|
||||
raw := c.String()
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(raw))
|
||||
}
|
||||
|
||||
// DecodeCursor parses a cursor produced by Cursor.Encode.
|
||||
// Returns the zero cursor on empty input.
|
||||
func DecodeCursor(s string) (Cursor, error) {
|
||||
if s == "" {
|
||||
return Cursor{}, nil
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return Cursor{}, fmt.Errorf("%w: bad cursor encoding: %v", ErrInvalidInput, err)
|
||||
}
|
||||
parts := strings.SplitN(string(raw), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return Cursor{}, fmt.Errorf("%w: cursor must be <ms>:<id>", ErrInvalidInput)
|
||||
}
|
||||
ms, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return Cursor{}, fmt.Errorf("%w: cursor ms: %v", ErrInvalidInput, err)
|
||||
}
|
||||
return Cursor{Ms: ms, ID: parts[1]}, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package races
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/x0gp/server/internal/lobby"
|
||||
)
|
||||
|
||||
// LiveStore is a thin facade over PgStore that exposes only the live
|
||||
// race persistence methods. It implements the lobby.Persistence
|
||||
// contract so the lobby can write through it.
|
||||
//
|
||||
// Methods on this type are kept separate from the rest of PgStore
|
||||
// purely for clarity: the lobby-facing surface is small and
|
||||
// well-defined. All actual work is delegated to PgStore.
|
||||
type LiveStore struct {
|
||||
pg *PgStore
|
||||
}
|
||||
|
||||
// NewLiveStore wraps an existing PgStore.
|
||||
func NewLiveStore(pg *PgStore) *LiveStore { return &LiveStore{pg: pg} }
|
||||
|
||||
func (s *LiveStore) UpsertRace(ctx context.Context, r lobby.RaceMeta) error {
|
||||
return s.pg.UpsertLive(ctx, r)
|
||||
}
|
||||
|
||||
func (s *LiveStore) DeleteRace(ctx context.Context, raceID string) error {
|
||||
return s.pg.DeleteLive(ctx, raceID)
|
||||
}
|
||||
|
||||
func (s *LiveStore) AddDriver(ctx context.Context, raceID, driverID string, slot int) error {
|
||||
return s.pg.AddLiveDriver(ctx, raceID, driverID, slot)
|
||||
}
|
||||
|
||||
func (s *LiveStore) RemoveDriver(ctx context.Context, raceID, driverID string) error {
|
||||
return s.pg.RemoveLiveDriver(ctx, raceID, driverID)
|
||||
}
|
||||
|
||||
func (s *LiveStore) SetRaceStatus(ctx context.Context, raceID string, status lobby.RaceStatus, startedMs int64) error {
|
||||
return s.pg.SetLiveStatus(ctx, raceID, status, startedMs)
|
||||
}
|
||||
|
||||
func (s *LiveStore) UpsertDriver(ctx context.Context, d lobby.DriverMeta) error {
|
||||
return s.pg.UpsertLobbyDriver(ctx, d)
|
||||
}
|
||||
|
||||
func (s *LiveStore) DeleteDriver(ctx context.Context, driverID string) error {
|
||||
return s.pg.DeleteLobbyDriver(ctx, driverID)
|
||||
}
|
||||
|
||||
// ListAllRaces returns every live row in the unified races table.
|
||||
// Used by RestoreFromDB to rehydrate the in-memory lobby.
|
||||
func (s *LiveStore) ListAllRaces(ctx context.Context) ([]lobby.RaceMeta, error) {
|
||||
return s.pg.ListAllRaces(ctx)
|
||||
}
|
||||
|
||||
// ListAllDrivers returns all lobby drivers from the lobby_drivers table.
|
||||
func (s *LiveStore) ListAllDrivers(ctx context.Context) ([]lobby.DriverMeta, error) {
|
||||
return s.pg.ListLobbyDrivers(ctx)
|
||||
}
|
||||
|
||||
// ListLivePaged exposes a keyset page of live races.
|
||||
func (s *LiveStore) ListLivePaged(ctx context.Context, statuses []string, trackID string, cur Cursor, limit int) ([]lobby.RaceMeta, error) {
|
||||
return s.pg.ListLivePaged(ctx, statuses, trackID, cur, limit)
|
||||
}
|
||||
|
||||
// ListUpcoming returns the next N open live races.
|
||||
func (s *LiveStore) ListUpcoming(ctx context.Context, limit int) ([]lobby.RaceMeta, error) {
|
||||
return s.pg.ListLiveUpcoming(ctx, limit)
|
||||
}
|
||||
|
||||
// Exec exposes raw SQL for the seeder.
|
||||
func (s *LiveStore) Exec(ctx context.Context, sql string) (int64, error) {
|
||||
return s.pg.Exec(ctx, sql)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
package races
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/x0gp/server/internal/lobby"
|
||||
)
|
||||
|
||||
// Service composes lobby.Service (live) with PgStore (finished/plans/queue)
|
||||
// and exposes the read/join methods used by the HTTP handlers and the
|
||||
// scheduler.
|
||||
type Service struct {
|
||||
pg *PgStore
|
||||
lobby *lobby.Service
|
||||
live *LiveStore // optional: when set, live reads go through DB
|
||||
now func() time.Time
|
||||
maxIDs int
|
||||
}
|
||||
|
||||
// NewService wires a Service. now is overridable for tests.
|
||||
func NewService(pg *PgStore, lb *lobby.Service) *Service {
|
||||
return &Service{
|
||||
pg: pg,
|
||||
lobby: lb,
|
||||
now: time.Now,
|
||||
maxIDs: 3,
|
||||
}
|
||||
}
|
||||
|
||||
// SetLiveStore installs the LiveStore (DB-backed live races). When set,
|
||||
// the read side of the races list pulls from Postgres instead of the
|
||||
// in-memory lobby. Writes still go through lobby.Service.
|
||||
func (s *Service) SetLiveStore(live *LiveStore) {
|
||||
s.live = live
|
||||
}
|
||||
|
||||
// RestoreFromDB rehydrates the in-memory lobby from the live_races and
|
||||
// lobby_drivers tables. Called at startup so that active races and
|
||||
// driver presence survive a server restart. If LiveStore is not
|
||||
// configured this is a no-op.
|
||||
func (s *Service) RestoreFromDB(ctx context.Context) (int, int, error) {
|
||||
if s.live == nil {
|
||||
return 0, 0, nil
|
||||
}
|
||||
races, err := s.live.ListAllRaces(ctx)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("restore races: %w", err)
|
||||
}
|
||||
drivers, err := s.live.ListAllDrivers(ctx)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("restore drivers: %w", err)
|
||||
}
|
||||
// Restore drivers first so the race restore can attach them.
|
||||
for _, d := range drivers {
|
||||
// We bypass the normal AddDriver because we don't want to
|
||||
// re-trigger a persistence write for already-persisted rows.
|
||||
// Use the internal helper if available; otherwise go through
|
||||
// AddDriver+SetDriverProfile and accept a no-op mirror.
|
||||
_, _ = s.lobby.AddDriver(d.ID, d.Name, "")
|
||||
s.lobby.SetDriverProfile(d.ID, d.Nickname, d.AvatarURL, d.ClanID, d.ClanTag)
|
||||
}
|
||||
for _, r := range races {
|
||||
// We bypass CreateRace to avoid duplicating persistence writes
|
||||
// for races that are already in the DB. AddRace (a new helper
|
||||
// on lobby.Service) inserts the race without mirroring.
|
||||
s.lobby.AddRace(r)
|
||||
for _, did := range r.DriverIDs {
|
||||
_ = s.lobby.AddDriverToRace(did, r.ID)
|
||||
}
|
||||
}
|
||||
return len(races), len(drivers), nil
|
||||
}
|
||||
|
||||
// SetMaxUpcoming overrides the default "next N" queue size (default 3).
|
||||
func (s *Service) SetMaxUpcoming(n int) {
|
||||
if n > 0 && n <= 16 {
|
||||
s.maxIDs = n
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List (keyset, mixed live + finished)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ListFilter is the parsed query for ListRaces.
|
||||
type ListFilter struct {
|
||||
Statuses []StatusFilter // resolved filter; empty = all
|
||||
TrackID string
|
||||
Cursor Cursor
|
||||
Limit int
|
||||
}
|
||||
|
||||
// RaceItem is one row in the paginated result.
|
||||
type RaceItem struct {
|
||||
Source string // "live" | "finished"
|
||||
Meta lobby.RaceMeta // always populated
|
||||
// Podium is populated for finished races only.
|
||||
Podium []PodiumEntry
|
||||
}
|
||||
|
||||
// ListResult is a keyset page.
|
||||
type ListResult struct {
|
||||
Items []RaceItem
|
||||
NextCursor string // empty when no more pages
|
||||
}
|
||||
|
||||
// ListRaces returns one keyset page of races matching the filter.
|
||||
//
|
||||
// Pagination strategy: live races (in-memory) are stable per snapshot but
|
||||
// don't have a DB-side index. To honour the "keyset" contract we page
|
||||
// live and finished sources independently and merge by sort key:
|
||||
//
|
||||
// * Live races use started_ms if set, otherwise created_ms.
|
||||
// * Finished races use finished_ms.
|
||||
//
|
||||
// Pages are pulled live-first when any "active" status is in the filter,
|
||||
// or finished-first when status=finished. The cursor encodes
|
||||
// ("live"|"finished", sort_ms, race_id) so successive pages stay in order.
|
||||
func (s *Service) ListRaces(ctx context.Context, f ListFilter) (ListResult, error) {
|
||||
limit := f.Limit
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
|
||||
wantFinished := false
|
||||
wantLive := false
|
||||
for _, st := range f.Statuses {
|
||||
if st == StatusAll {
|
||||
wantLive = true
|
||||
wantFinished = true
|
||||
break
|
||||
}
|
||||
if st.matchesLive("finished") || st == StatusFinished {
|
||||
wantFinished = true
|
||||
}
|
||||
if st.matchesLive("lobby") || st == StatusLobby ||
|
||||
st == StatusRacing {
|
||||
wantLive = true
|
||||
}
|
||||
}
|
||||
|
||||
// Parse cursor split (source/ms/id) if present.
|
||||
curSource, curMs, curID := "live", int64(0), ""
|
||||
if !f.Cursor.IsZero() {
|
||||
curSource, curMs, curID = splitCursor(f.Cursor)
|
||||
}
|
||||
|
||||
// Collect candidates from each source up to (limit+1) entries.
|
||||
out := make([]RaceItem, 0, limit+1)
|
||||
|
||||
if wantLive {
|
||||
live, err := s.collectLive(ctx, f.Statuses, f.TrackID, curSource, curMs, curID, limit+1)
|
||||
if err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
out = append(out, live...)
|
||||
}
|
||||
if wantFinished {
|
||||
finished, err := s.collectFinished(ctx, f.Statuses, f.TrackID, curSource, curMs, curID, limit+1)
|
||||
if err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
out = append(out, finished...)
|
||||
}
|
||||
|
||||
// Sort by (sort_ms DESC, id ASC), stable.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
mi := sortMs(out[i].Meta)
|
||||
mj := sortMs(out[j].Meta)
|
||||
if mi != mj {
|
||||
return mi > mj
|
||||
}
|
||||
return out[i].Meta.ID < out[j].Meta.ID
|
||||
})
|
||||
|
||||
hasMore := false
|
||||
if len(out) > limit {
|
||||
hasMore = true
|
||||
out = out[:limit]
|
||||
}
|
||||
|
||||
res := ListResult{Items: out}
|
||||
if hasMore && len(out) > 0 {
|
||||
last := out[len(out)-1]
|
||||
res.NextCursor = Cursor{
|
||||
Ms: sortMs(last.Meta),
|
||||
ID: last.Meta.ID,
|
||||
}.Encode()
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func sortMs(m lobby.RaceMeta) int64 {
|
||||
if m.Status == lobby.RaceStatusFinished {
|
||||
// Finished meta from PgStore has no FinishedMs field; StartedMs is the
|
||||
// best proxy for the original finished time within the lobby shape.
|
||||
if m.StartedMs > 0 {
|
||||
return m.StartedMs
|
||||
}
|
||||
return m.CreatedMs
|
||||
}
|
||||
if m.StartedMs > 0 {
|
||||
return m.StartedMs
|
||||
}
|
||||
return m.CreatedMs
|
||||
}
|
||||
|
||||
// collectLive fetches one page of live races. If s.live (LiveStore) is
|
||||
// set, the data comes from Postgres; otherwise it falls back to the
|
||||
// in-memory lobby.
|
||||
func (s *Service) collectLive(ctx context.Context, filters []StatusFilter, trackID, curSource string, curMs int64, curID string, limit int) ([]RaceItem, error) {
|
||||
if s.live != nil && curSource == "live" {
|
||||
return s.collectLiveDB(ctx, filters, trackID, curMs, curID, limit)
|
||||
}
|
||||
// Fallback: in-memory lobby. The in-memory branch still applies the
|
||||
// cursor manually because the legacy splitCursor format encodes the
|
||||
// source in the id suffix.
|
||||
all := s.lobby.ListRaces()
|
||||
out := make([]RaceItem, 0, limit)
|
||||
for _, r := range all {
|
||||
if !statusMatches(filters, string(r.Status)) {
|
||||
continue
|
||||
}
|
||||
if trackID != "" && r.TrackID != trackID {
|
||||
continue
|
||||
}
|
||||
sortKey := sortMs(r)
|
||||
if curSource == "live" {
|
||||
if sortKey < curMs || (sortKey == curMs && r.ID >= curID) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, RaceItem{Source: "live", Meta: r})
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) collectLiveDB(ctx context.Context, filters []StatusFilter, trackID string, curMs int64, curID string, limit int) ([]RaceItem, error) {
|
||||
statuses := filterToStatusStrings(filters)
|
||||
cur := Cursor{Ms: curMs, ID: curID}
|
||||
rows, err := s.live.ListLivePaged(ctx, statuses, trackID, cur, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list live: %w", err)
|
||||
}
|
||||
out := make([]RaceItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
// Skip anything that doesn't match the in-memory status filter
|
||||
// (DB already filtered; this is a safety belt).
|
||||
if !statusMatches(filters, string(r.Status)) {
|
||||
continue
|
||||
}
|
||||
out = append(out, RaceItem{Source: "live", Meta: r})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// filterToStatusStrings returns the list of status values that the DB
|
||||
// query should match. StatusAll expands to the three live statuses
|
||||
// (lobby, countdown, racing). The caller adds the finished source
|
||||
// separately.
|
||||
func filterToStatusStrings(filters []StatusFilter) []string {
|
||||
if len(filters) == 0 {
|
||||
return []string{string(lobby.RaceStatusLobby), string(lobby.RaceStatusCountdown), string(lobby.RaceStatusRacing)}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, f := range filters {
|
||||
switch f {
|
||||
case StatusAll:
|
||||
return []string{string(lobby.RaceStatusLobby), string(lobby.RaceStatusCountdown), string(lobby.RaceStatusRacing)}
|
||||
case StatusRacing:
|
||||
seen["racing"] = true
|
||||
case StatusLobby:
|
||||
seen["lobby"] = true
|
||||
case StatusFinished:
|
||||
// finished is served by collectFinished; skip here.
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for k := range seen {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) collectFinished(ctx context.Context, filters []StatusFilter, trackID, curSource string, curMs int64, curID string, limit int) ([]RaceItem, error) {
|
||||
if !statusWantsFinished(filters) {
|
||||
return nil, nil
|
||||
}
|
||||
cur := Cursor{}
|
||||
if curSource == "finished" {
|
||||
cur = Cursor{Ms: curMs, ID: curID}
|
||||
}
|
||||
rows, err := s.pg.ListFinished(ctx, trackID, cur, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]RaceItem, 0, len(rows))
|
||||
for _, f := range rows {
|
||||
out = append(out, RaceItem{Source: "finished", Meta: f.ToLobbyMeta(), Podium: f.Podium})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func statusMatches(filters []StatusFilter, liveStatus string) bool {
|
||||
if len(filters) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, f := range filters {
|
||||
if f == StatusAll {
|
||||
return true
|
||||
}
|
||||
if f.matchesLive(liveStatus) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// statusWantsFinished is true if the filter list allows finished races
|
||||
// through. Used to gate the Postgres query in collectFinished.
|
||||
func statusWantsFinished(filters []StatusFilter) bool {
|
||||
if len(filters) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, f := range filters {
|
||||
if f == StatusAll || f == StatusFinished {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitCursor is a no-op shim; we encode source in the id field for v1
|
||||
// simplicity (suffix ":src"). Returns ("live"|"finished", ms, id).
|
||||
func splitCursor(c Cursor) (string, int64, string) {
|
||||
id := c.ID
|
||||
src := "live"
|
||||
// Convention: id may end with ":finished" or ":live". If absent, treat as live.
|
||||
for _, suf := range []string{":finished", ":live"} {
|
||||
if len(id) > len(suf) && id[len(id)-len(suf):] == suf {
|
||||
id = id[:len(id)-len(suf)]
|
||||
src = suf[1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
return src, c.Ms, id
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upcoming (next N) and queue join
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpcomingItem is one row in the upcoming list.
|
||||
type UpcomingItem struct {
|
||||
Meta lobby.RaceMeta
|
||||
QueueLen int
|
||||
PlanID string
|
||||
StartAtMs int64 // best-effort: StartedMs for live races, otherwise CreatedMs+offset
|
||||
}
|
||||
|
||||
// Upcoming returns the next N open (lobby|countdown) races, soonest first.
|
||||
func (s *Service) Upcoming(ctx context.Context, limit int) ([]UpcomingItem, error) {
|
||||
if limit <= 0 {
|
||||
limit = s.maxIDs
|
||||
}
|
||||
if limit > 16 {
|
||||
limit = 16
|
||||
}
|
||||
// When LiveStore is configured, read upcoming from Postgres.
|
||||
if s.live != nil {
|
||||
return s.upcomingDB(ctx, limit)
|
||||
}
|
||||
all := s.lobby.ListRaces()
|
||||
out := make([]UpcomingItem, 0, limit)
|
||||
for _, r := range all {
|
||||
if r.Status != lobby.RaceStatusLobby && r.Status != lobby.RaceStatusCountdown {
|
||||
continue
|
||||
}
|
||||
n, err := s.pg.CountQueueByRace(ctx, r.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startAt := r.StartedMs
|
||||
if startAt == 0 {
|
||||
startAt = r.CreatedMs
|
||||
}
|
||||
out = append(out, UpcomingItem{
|
||||
Meta: r,
|
||||
QueueLen: n,
|
||||
StartAtMs: startAt,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].StartAtMs != out[j].StartAtMs {
|
||||
return out[i].StartAtMs < out[j].StartAtMs
|
||||
}
|
||||
return out[i].Meta.ID < out[j].Meta.ID
|
||||
})
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) upcomingDB(ctx context.Context, limit int) ([]UpcomingItem, error) {
|
||||
rows, err := s.live.ListUpcoming(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list upcoming: %w", err)
|
||||
}
|
||||
out := make([]UpcomingItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
n, err := s.pg.CountQueueByRace(ctx, r.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startAt := r.StartedMs
|
||||
if startAt == 0 {
|
||||
startAt = r.CreatedMs
|
||||
}
|
||||
out = append(out, UpcomingItem{
|
||||
Meta: r,
|
||||
QueueLen: n,
|
||||
StartAtMs: startAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// JoinUpcomingResult is what JoinUpcoming returns.
|
||||
type JoinUpcomingResult struct {
|
||||
Joined []QueueEntry
|
||||
Skipped []string // race ids that were already at capacity
|
||||
}
|
||||
|
||||
// JoinUpcoming puts the driver into the queue for each of the next N
|
||||
// upcoming races. Idempotent per (driver, race).
|
||||
func (s *Service) JoinUpcoming(ctx context.Context, driverID string, limit int) (JoinUpcomingResult, error) {
|
||||
if driverID == "" {
|
||||
return JoinUpcomingResult{}, fmt.Errorf("%w: driver_id required", ErrInvalidInput)
|
||||
}
|
||||
up, err := s.Upcoming(ctx, limit)
|
||||
if err != nil {
|
||||
return JoinUpcomingResult{}, err
|
||||
}
|
||||
res := JoinUpcomingResult{Joined: make([]QueueEntry, 0, len(up))}
|
||||
for _, u := range up {
|
||||
// Skip races that are full; surfaced in Skipped for the caller.
|
||||
if len(u.Meta.DriverIDs) >= u.Meta.MaxCars {
|
||||
res.Skipped = append(res.Skipped, u.Meta.ID)
|
||||
continue
|
||||
}
|
||||
// Best-effort auto-attach: if the race is still lobby and the driver
|
||||
// can fit, AddDriverToRace moves them in. The queue entry remains
|
||||
// for diagnostics.
|
||||
if u.Meta.Status == lobby.RaceStatusLobby && len(u.Meta.DriverIDs) < u.Meta.MaxCars {
|
||||
_ = s.lobby.AddDriverToRace(driverID, u.Meta.ID)
|
||||
}
|
||||
q, err := s.pg.Enqueue(ctx, driverID, u.Meta.ID, u.PlanID)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Joined = append(res.Joined, q)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// LeaveQueue removes a single (driver, race) entry.
|
||||
func (s *Service) LeaveQueue(ctx context.Context, driverID, raceID string) error {
|
||||
return s.pg.Dequeue(ctx, driverID, raceID)
|
||||
}
|
||||
|
||||
// ListQueue returns the driver's queue entries.
|
||||
func (s *Service) ListQueue(ctx context.Context, driverID string) ([]QueueEntry, error) {
|
||||
return s.pg.ListQueueByDriver(ctx, driverID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finished-race snapshot hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SnapshotFinished is the input to the "persist on finish" hook. Race is
|
||||
// a copy of the in-memory meta. Result is filled by the engine.
|
||||
type SnapshotFinished struct {
|
||||
Meta lobby.RaceMeta
|
||||
FinishedMs int64
|
||||
TotalLaps int
|
||||
WinnerDriverID string
|
||||
WinnerName string
|
||||
BestLapMs int64
|
||||
}
|
||||
|
||||
// PersistFinished upserts the finished race into Postgres.
|
||||
func (s *Service) PersistFinished(ctx context.Context, sf SnapshotFinished) error {
|
||||
startedMs := sf.Meta.StartedMs
|
||||
finishedMs := sf.FinishedMs
|
||||
if finishedMs == 0 {
|
||||
finishedMs = s.now().UnixMilli()
|
||||
}
|
||||
r := FinishedRace{
|
||||
ID: sf.Meta.ID,
|
||||
Name: sf.Meta.Name,
|
||||
TrackID: sf.Meta.TrackID,
|
||||
MaxCars: sf.Meta.MaxCars,
|
||||
Laps: sf.Meta.Laps,
|
||||
TimeLimitS: sf.Meta.TimeLimitS,
|
||||
DriverIDs: append([]string(nil), sf.Meta.DriverIDs...),
|
||||
Status: "finished",
|
||||
CreatedMs: sf.Meta.CreatedMs,
|
||||
StartedMs: startedMs,
|
||||
FinishedMs: finishedMs,
|
||||
DurationMs: finishedMs - startedMs,
|
||||
TotalLaps: sf.TotalLaps,
|
||||
TotalDrivers: len(sf.Meta.DriverIDs),
|
||||
WinnerDriverID: sf.WinnerDriverID,
|
||||
WinnerName: sf.WinnerName,
|
||||
BestLapMs: sf.BestLapMs,
|
||||
}
|
||||
return s.pg.InsertFinished(ctx, r)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Race plans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreatePlanInput is what the HTTP handler hands to the service.
|
||||
type CreatePlanInput struct {
|
||||
ID string
|
||||
Name string
|
||||
TrackID string
|
||||
MaxCars int
|
||||
Laps int
|
||||
TimeLimitS int
|
||||
StartAtMs int64
|
||||
IntervalS int
|
||||
Count int
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// CreatePlan validates and persists a plan.
|
||||
func (s *Service) CreatePlan(ctx context.Context, in CreatePlanInput) (RacePlan, error) {
|
||||
if in.Name == "" {
|
||||
return RacePlan{}, fmt.Errorf("%w: name required", ErrInvalidInput)
|
||||
}
|
||||
if in.MaxCars < 1 || in.MaxCars > 8 {
|
||||
return RacePlan{}, fmt.Errorf("%w: max_cars must be 1..8", ErrInvalidInput)
|
||||
}
|
||||
if in.StartAtMs <= 0 {
|
||||
return RacePlan{}, fmt.Errorf("%w: start_at_ms required", ErrInvalidInput)
|
||||
}
|
||||
if in.IntervalS < 0 {
|
||||
return RacePlan{}, fmt.Errorf("%w: interval_s must be >= 0", ErrInvalidInput)
|
||||
}
|
||||
if in.Count < 0 {
|
||||
return RacePlan{}, fmt.Errorf("%w: count must be >= 0", ErrInvalidInput)
|
||||
}
|
||||
if in.ID == "" {
|
||||
in.ID = fmt.Sprintf("plan-%d-%d", s.now().UnixMilli(), time.Now().UnixNano()%1000)
|
||||
}
|
||||
if in.TrackID == "" {
|
||||
in.TrackID = "default"
|
||||
}
|
||||
p := RacePlan{
|
||||
ID: in.ID,
|
||||
Name: in.Name,
|
||||
TrackID: in.TrackID,
|
||||
MaxCars: in.MaxCars,
|
||||
Laps: in.Laps,
|
||||
TimeLimitS: in.TimeLimitS,
|
||||
StartAtMs: in.StartAtMs,
|
||||
IntervalS: in.IntervalS,
|
||||
Count: in.Count,
|
||||
Enabled: in.Enabled,
|
||||
NextFireMs: in.StartAtMs,
|
||||
}
|
||||
if err := s.pg.CreatePlan(ctx, p); err != nil {
|
||||
return RacePlan{}, err
|
||||
}
|
||||
return s.pg.GetPlan(ctx, p.ID)
|
||||
}
|
||||
|
||||
// ListPlans returns plans in start_at ascending order.
|
||||
func (s *Service) ListPlans(ctx context.Context, cur Cursor, limit int) ([]RacePlan, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
return s.pg.ListPlans(ctx, cur, limit)
|
||||
}
|
||||
|
||||
// DeletePlan removes a plan by id.
|
||||
func (s *Service) DeletePlan(ctx context.Context, id string) error {
|
||||
return s.pg.DeletePlan(ctx, id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Scheduler materialises due race plans into lobby races every tick.
|
||||
type Scheduler struct {
|
||||
pg *PgStore
|
||||
lobby *lobby.Service
|
||||
tick time.Duration
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewScheduler creates a scheduler. tick default is 5s.
|
||||
func NewScheduler(pg *PgStore, lb *lobby.Service, tick time.Duration) *Scheduler {
|
||||
if tick <= 0 {
|
||||
tick = 5 * time.Second
|
||||
}
|
||||
return &Scheduler{
|
||||
pg: pg,
|
||||
lobby: lb,
|
||||
tick: tick,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Run blocks until Stop is called. Safe to launch in a goroutine.
|
||||
func (s *Scheduler) Run(ctx context.Context) {
|
||||
defer close(s.doneCh)
|
||||
t := time.NewTicker(s.tick)
|
||||
defer t.Stop()
|
||||
// Fire once on start so newly-enabled plans materialise promptly.
|
||||
s.tickOnce(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-t.C:
|
||||
s.tickOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop signals the scheduler to exit.
|
||||
func (s *Scheduler) Stop() {
|
||||
s.once.Do(func() { close(s.stopCh) })
|
||||
}
|
||||
|
||||
// Done blocks until the scheduler goroutine has returned.
|
||||
func (s *Scheduler) Done() <-chan struct{} { return s.doneCh }
|
||||
|
||||
func (s *Scheduler) tickOnce(ctx context.Context) {
|
||||
now := s.now().UnixMilli()
|
||||
plans, err := s.pg.DuePlans(ctx, now, 32)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, p := range plans {
|
||||
meta, err := s.lobby.CreateRace(lobby.CreateRaceOptions{
|
||||
Name: p.Name,
|
||||
TrackID: p.TrackID,
|
||||
MaxCars: p.MaxCars,
|
||||
Laps: p.Laps,
|
||||
TimeLimitS: p.TimeLimitS,
|
||||
})
|
||||
if err != nil {
|
||||
// Could not create; advance anyway to avoid hot-loop on the
|
||||
// same plan.
|
||||
next := p.NextFireMs
|
||||
if p.IntervalS > 0 {
|
||||
next = next + int64(p.IntervalS)*1000
|
||||
}
|
||||
_ = s.pg.AdvancePlan(ctx, p.ID, next)
|
||||
continue
|
||||
}
|
||||
// Compute next fire time.
|
||||
next := p.NextFireMs
|
||||
if p.IntervalS > 0 {
|
||||
next = p.NextFireMs + int64(p.IntervalS)*1000
|
||||
}
|
||||
_ = s.advance(ctx, p, next)
|
||||
// Auto-attach queued drivers to the new race (best-effort).
|
||||
s.attachQueued(ctx, meta.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) advance(ctx context.Context, p RacePlan, nextFireMs int64) error {
|
||||
if nextFireMs == 0 && p.IntervalS == 0 {
|
||||
// Single-shot: disable.
|
||||
return s.pg.SetPlanEnabled(ctx, p.ID, false)
|
||||
}
|
||||
return s.pg.AdvancePlan(ctx, p.ID, nextFireMs)
|
||||
}
|
||||
|
||||
func (s *Scheduler) attachQueued(ctx context.Context, raceID string) {
|
||||
entries, err := s.pg.ListQueueByRace(ctx, raceID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, q := range entries {
|
||||
_, _ = s.lobby.AddDriver(q.DriverID, "", "") //nolint:errcheck // best-effort
|
||||
_ = s.lobby.AddDriverToRace(q.DriverID, raceID)
|
||||
_ = s.pg.Dequeue(ctx, q.DriverID, raceID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) now() time.Time { return time.Now() }
|
||||
@@ -0,0 +1,803 @@
|
||||
package races
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/x0gp/server/internal/lobby"
|
||||
)
|
||||
|
||||
// PodiumEntry is one row in the top-3 of a finished race. Ordered by
|
||||
// Position (1..3). DriverID/Name are denormalised from the future
|
||||
// drivers table; TotalTimeMs is the driver's total race time.
|
||||
type PodiumEntry struct {
|
||||
Position int `json:"position" example:"1"`
|
||||
DriverID string `json:"driver_id" example:"driver-alice"`
|
||||
Name string `json:"name" example:"driver-alice"`
|
||||
TotalTimeMs int64 `json:"total_time_ms" example:"123456"`
|
||||
}
|
||||
|
||||
// FinishedRace is the on-disk shape of a completed race. Mirrors the
|
||||
// lobby.RaceMeta plus outcome fields populated by the engine at finish.
|
||||
type FinishedRace struct {
|
||||
ID string
|
||||
Name string
|
||||
TrackID string
|
||||
MaxCars int
|
||||
Laps int
|
||||
TimeLimitS int
|
||||
DriverIDs []string
|
||||
Status string // "finished" | "cancelled"
|
||||
CreatedMs int64
|
||||
StartedMs int64
|
||||
FinishedMs int64
|
||||
DurationMs int64
|
||||
TotalLaps int
|
||||
TotalDrivers int
|
||||
WinnerDriverID string
|
||||
WinnerName string
|
||||
BestLapMs int64
|
||||
Podium []PodiumEntry
|
||||
}
|
||||
|
||||
// RacePlan is a recurring or one-off scheduled race description.
|
||||
type RacePlan struct {
|
||||
ID string
|
||||
Name string
|
||||
TrackID string
|
||||
MaxCars int
|
||||
Laps int
|
||||
TimeLimitS int
|
||||
StartAtMs int64
|
||||
IntervalS int
|
||||
Count int
|
||||
Enabled bool
|
||||
CreatedMs int64
|
||||
UpdatedMs int64
|
||||
NextFireMs int64
|
||||
FiresDone int
|
||||
}
|
||||
|
||||
// QueueEntry is a single (driver, race) subscription.
|
||||
type QueueEntry struct {
|
||||
DriverID string
|
||||
RaceID string
|
||||
PlanID string
|
||||
EnqueuedMs int64
|
||||
}
|
||||
|
||||
// PgStore is the Postgres-backed half of the races package.
|
||||
type PgStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPgStore wraps an open pgxpool.
|
||||
func NewPgStore(pool *pgxpool.Pool) *PgStore {
|
||||
return &PgStore{pool: pool}
|
||||
}
|
||||
|
||||
// Exec exposes a raw Exec for the seeder / maintenance scripts.
|
||||
func (s *PgStore) Exec(ctx context.Context, sql string) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, sql)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// ListTrackIDs returns all track ids from the tracks table. Used by
|
||||
// the seeder to bind race_plans/finished_races to real catalog entries.
|
||||
func (s *PgStore) ListTrackIDs(ctx context.Context) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id FROM tracks ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list track ids: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live race surface (status in lobby | countdown | racing).
|
||||
//
|
||||
// The unified `races` table holds both live and finished rows. Methods
|
||||
// below are the write-side mirror for lobby.Service; reads flow through
|
||||
// races.Service via ListRacesPaged (status filter) and ListUpcoming.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpsertLive mirrors a lobby.RaceMeta row. Only used for status in
|
||||
// (lobby, countdown, racing). Status is enforced at the call site.
|
||||
func (s *PgStore) UpsertLive(ctx context.Context, r lobby.RaceMeta) error {
|
||||
now := time.Now().UnixMilli()
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO races
|
||||
(id, name, track_id, max_cars, laps, time_limit_s, status,
|
||||
created_ms, started_ms, finished_ms, updated_ms, podium)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10, '[]'::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
track_id = EXCLUDED.track_id,
|
||||
max_cars = EXCLUDED.max_cars,
|
||||
laps = EXCLUDED.laps,
|
||||
time_limit_s = EXCLUDED.time_limit_s,
|
||||
status = EXCLUDED.status,
|
||||
started_ms = EXCLUDED.started_ms,
|
||||
updated_ms = EXCLUDED.updated_ms`,
|
||||
r.ID, r.Name, r.TrackID, r.MaxCars, r.Laps, r.TimeLimitS, string(r.Status),
|
||||
r.CreatedMs, r.StartedMs, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert live race %s: %w", r.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteLive removes a live race row. The FK from race_drivers has
|
||||
// CASCADE so the related drivers are also removed.
|
||||
func (s *PgStore) DeleteLive(ctx context.Context, raceID string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM races WHERE id = $1 AND status IN ('lobby','countdown','racing')`, raceID)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddLiveDriver adds a driver to a live race (slot=position). Idempotent.
|
||||
func (s *PgStore) AddLiveDriver(ctx context.Context, raceID, driverID string, slot int) error {
|
||||
now := time.Now().UnixMilli()
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO race_drivers (race_id, driver_id, slot, joined_ms)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (race_id, driver_id) DO UPDATE SET slot = EXCLUDED.slot`,
|
||||
raceID, driverID, slot, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add live driver %s to %s: %w", driverID, raceID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveLiveDriver removes a driver from a live race.
|
||||
func (s *PgStore) RemoveLiveDriver(ctx context.Context, raceID, driverID string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM race_drivers WHERE race_id = $1 AND driver_id = $2`,
|
||||
raceID, driverID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetLiveStatus updates the status (and started_ms) of a live race.
|
||||
func (s *PgStore) SetLiveStatus(ctx context.Context, raceID string, status lobby.RaceStatus, startedMs int64) error {
|
||||
now := time.Now().UnixMilli()
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE races
|
||||
SET status = $2, started_ms = $3, updated_ms = $4
|
||||
WHERE id = $1 AND status IN ('lobby','countdown','racing')`,
|
||||
raceID, string(status), startedMs, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListLivePaged returns one keyset page of live races.
|
||||
func (s *PgStore) ListLivePaged(ctx context.Context, statuses []string, trackID string, cur Cursor, limit int) ([]lobby.RaceMeta, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
args := []any{}
|
||||
conds := []string{"status IN ('lobby','countdown','racing')"}
|
||||
if len(statuses) > 0 {
|
||||
args = append(args, statuses)
|
||||
conds = append(conds, fmt.Sprintf("status = ANY($%d)", len(args)))
|
||||
}
|
||||
if trackID != "" {
|
||||
args = append(args, trackID)
|
||||
conds = append(conds, fmt.Sprintf("track_id = $%d", len(args)))
|
||||
}
|
||||
if !cur.IsZero() {
|
||||
args = append(args, cur.Ms, cur.ID)
|
||||
conds = append(conds, fmt.Sprintf("(COALESCE(started_ms, created_ms), id) < ($%d, $%d)", len(args)-1, len(args)))
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
q := `SELECT id, name, track_id, max_cars, laps, time_limit_s, status,
|
||||
created_ms, started_ms
|
||||
FROM races
|
||||
WHERE ` + joinAnd(conds) + `
|
||||
ORDER BY COALESCE(started_ms, created_ms) DESC, id ASC
|
||||
LIMIT $` + fmt.Sprintf("%d", len(args))
|
||||
rows, err := s.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]lobby.RaceMeta, 0)
|
||||
for rows.Next() {
|
||||
var r lobby.RaceMeta
|
||||
var status string
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.TrackID, &r.MaxCars, &r.Laps, &r.TimeLimitS,
|
||||
&status, &r.CreatedMs, &r.StartedMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Status = lobby.RaceStatus(status)
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
ids, err := s.listDriverIDs(ctx, out[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i].DriverIDs = ids
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListLiveUpcoming returns up to limit live races in (lobby, countdown)
|
||||
// ordered soonest first.
|
||||
func (s *PgStore) ListLiveUpcoming(ctx context.Context, limit int) ([]lobby.RaceMeta, error) {
|
||||
if limit <= 0 {
|
||||
limit = 3
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, name, track_id, max_cars, laps, time_limit_s, status,
|
||||
created_ms, started_ms
|
||||
FROM races
|
||||
WHERE status IN ('lobby', 'countdown')
|
||||
ORDER BY COALESCE(started_ms, created_ms) ASC, id ASC
|
||||
LIMIT $1`, limit+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]lobby.RaceMeta, 0)
|
||||
for rows.Next() {
|
||||
var r lobby.RaceMeta
|
||||
var status string
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.TrackID, &r.MaxCars, &r.Laps, &r.TimeLimitS,
|
||||
&status, &r.CreatedMs, &r.StartedMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Status = lobby.RaceStatus(status)
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
ids, err := s.listDriverIDs(ctx, out[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i].DriverIDs = ids
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetLive fetches a single live race.
|
||||
func (s *PgStore) GetLive(ctx context.Context, id string) (lobby.RaceMeta, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, name, track_id, max_cars, laps, time_limit_s, status,
|
||||
created_ms, started_ms
|
||||
FROM races WHERE id = $1 AND status IN ('lobby','countdown','racing')`, id)
|
||||
var r lobby.RaceMeta
|
||||
var status string
|
||||
if err := row.Scan(&r.ID, &r.Name, &r.TrackID, &r.MaxCars, &r.Laps, &r.TimeLimitS,
|
||||
&status, &r.CreatedMs, &r.StartedMs); err != nil {
|
||||
return lobby.RaceMeta{}, err
|
||||
}
|
||||
r.Status = lobby.RaceStatus(status)
|
||||
ids, err := s.listDriverIDs(ctx, id)
|
||||
if err != nil {
|
||||
return lobby.RaceMeta{}, err
|
||||
}
|
||||
r.DriverIDs = ids
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// ListAllRaces returns every row in the races table (live+finished).
|
||||
// Used by RestoreFromDB to rehydrate the lobby.
|
||||
func (s *PgStore) ListAllRaces(ctx context.Context) ([]lobby.RaceMeta, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, name, track_id, max_cars, laps, time_limit_s, status,
|
||||
created_ms, started_ms
|
||||
FROM races
|
||||
WHERE status IN ('lobby','countdown','racing')
|
||||
ORDER BY created_ms ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]lobby.RaceMeta, 0)
|
||||
for rows.Next() {
|
||||
var r lobby.RaceMeta
|
||||
var status string
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.TrackID, &r.MaxCars, &r.Laps, &r.TimeLimitS,
|
||||
&status, &r.CreatedMs, &r.StartedMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Status = lobby.RaceStatus(status)
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
ids, err := s.listDriverIDs(ctx, out[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i].DriverIDs = ids
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PgStore) listDriverIDs(ctx context.Context, raceID string) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT driver_id FROM race_drivers WHERE race_id = $1 ORDER BY slot ASC, joined_ms ASC`,
|
||||
raceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertLobbyDriver mirrors a lobby.DriverMeta row.
|
||||
func (s *PgStore) UpsertLobbyDriver(ctx context.Context, d lobby.DriverMeta) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO lobby_drivers
|
||||
(driver_id, name, nickname, avatar_url, clan_id, clan_tag,
|
||||
status, current_race_id, connected_ms, last_seen_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (driver_id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
nickname = EXCLUDED.nickname,
|
||||
avatar_url = EXCLUDED.avatar_url,
|
||||
clan_id = EXCLUDED.clan_id,
|
||||
clan_tag = EXCLUDED.clan_tag,
|
||||
status = EXCLUDED.status,
|
||||
current_race_id = EXCLUDED.current_race_id,
|
||||
last_seen_ms = EXCLUDED.last_seen_ms`,
|
||||
d.ID, d.Name, d.Nickname, d.AvatarURL, d.ClanID, d.ClanTag,
|
||||
string(d.Status), d.RaceID, d.ConnectedMs, d.LastSeenMs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert lobby driver %s: %w", d.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteLobbyDriver removes a lobby driver row.
|
||||
func (s *PgStore) DeleteLobbyDriver(ctx context.Context, driverID string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM lobby_drivers WHERE driver_id = $1`, driverID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListLobbyDrivers returns all lobby drivers.
|
||||
func (s *PgStore) ListLobbyDrivers(ctx context.Context) ([]lobby.DriverMeta, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT driver_id, name, nickname, avatar_url, clan_id, clan_tag,
|
||||
status, current_race_id, connected_ms, last_seen_ms
|
||||
FROM lobby_drivers ORDER BY last_seen_ms DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]lobby.DriverMeta, 0)
|
||||
for rows.Next() {
|
||||
var d lobby.DriverMeta
|
||||
var status string
|
||||
if err := rows.Scan(&d.ID, &d.Name, &d.Nickname, &d.AvatarURL, &d.ClanID, &d.ClanTag,
|
||||
&status, &d.RaceID, &d.ConnectedMs, &d.LastSeenMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Status = lobby.DriverStatus(status)
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// finished_races
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// InsertFinished upserts a finished race row.
|
||||
func (s *PgStore) InsertFinished(ctx context.Context, r FinishedRace) error {
|
||||
podium := r.Podium
|
||||
if podium == nil {
|
||||
podium = []PodiumEntry{}
|
||||
}
|
||||
podiumJSON, err := json.Marshal(podium)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal podium: %w", err)
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO races
|
||||
(id, name, track_id, max_cars, laps, time_limit_s,
|
||||
driver_ids, status, created_ms, started_ms, finished_ms, duration_ms,
|
||||
total_laps, total_drivers, winner_driver_id, winner_name, best_lap_ms,
|
||||
podium)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
status = EXCLUDED.status,
|
||||
finished_ms = EXCLUDED.finished_ms,
|
||||
duration_ms = EXCLUDED.duration_ms,
|
||||
total_laps = EXCLUDED.total_laps,
|
||||
total_drivers = EXCLUDED.total_drivers,
|
||||
winner_driver_id = EXCLUDED.winner_driver_id,
|
||||
winner_name = EXCLUDED.winner_name,
|
||||
best_lap_ms = EXCLUDED.best_lap_ms,
|
||||
podium = EXCLUDED.podium`,
|
||||
r.ID, r.Name, r.TrackID, r.MaxCars, r.Laps, r.TimeLimitS,
|
||||
r.DriverIDs, r.Status, r.CreatedMs, r.StartedMs, r.FinishedMs, r.DurationMs,
|
||||
r.TotalLaps, r.TotalDrivers, r.WinnerDriverID, r.WinnerName, r.BestLapMs,
|
||||
podiumJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert finished race %s: %w", r.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListFinished returns finished races matching the filter using keyset
|
||||
// pagination on (finished_ms DESC, id DESC). The cursor's Ms is the
|
||||
// last finished_ms from the previous page; rows strictly older are
|
||||
// returned. Tie-breaker on id.
|
||||
func (s *PgStore) ListFinished(ctx context.Context, trackID string, cur Cursor, limit int) ([]FinishedRace, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
args := []any{}
|
||||
conds := []string{"status IN ('finished','cancelled')"}
|
||||
if !cur.IsZero() {
|
||||
args = append(args, cur.Ms, cur.ID)
|
||||
conds = append(conds, fmt.Sprintf("(finished_ms, id) < ($%d, $%d)", len(args)-1, len(args)))
|
||||
}
|
||||
if trackID != "" {
|
||||
args = append(args, trackID)
|
||||
conds = append(conds, fmt.Sprintf("track_id = $%d", len(args)))
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
q := `SELECT id, name, track_id, max_cars, laps, time_limit_s,
|
||||
driver_ids, status, created_ms, started_ms, finished_ms, duration_ms,
|
||||
total_laps, total_drivers, winner_driver_id, winner_name, best_lap_ms,
|
||||
podium
|
||||
FROM races
|
||||
WHERE ` + joinAnd(conds) + `
|
||||
ORDER BY finished_ms DESC, id DESC
|
||||
LIMIT $` + fmt.Sprintf("%d", len(args))
|
||||
rows, err := s.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list finished: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanFinished(rows)
|
||||
}
|
||||
|
||||
func scanFinished(rows pgx.Rows) ([]FinishedRace, error) {
|
||||
out := make([]FinishedRace, 0)
|
||||
for rows.Next() {
|
||||
var r FinishedRace
|
||||
var podiumJSON []byte
|
||||
if err := rows.Scan(
|
||||
&r.ID, &r.Name, &r.TrackID,
|
||||
&r.MaxCars, &r.Laps, &r.TimeLimitS, &r.DriverIDs, &r.Status,
|
||||
&r.CreatedMs, &r.StartedMs, &r.FinishedMs, &r.DurationMs,
|
||||
&r.TotalLaps, &r.TotalDrivers, &r.WinnerDriverID, &r.WinnerName, &r.BestLapMs,
|
||||
&podiumJSON,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(podiumJSON) > 0 {
|
||||
if err := json.Unmarshal(podiumJSON, &r.Podium); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal podium: %w", err)
|
||||
}
|
||||
}
|
||||
if r.Podium == nil {
|
||||
r.Podium = []PodiumEntry{}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func joinAnd(parts []string) string {
|
||||
out := ""
|
||||
for i, p := range parts {
|
||||
if i > 0 {
|
||||
out += " AND "
|
||||
}
|
||||
out += p
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// race_plans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreatePlan inserts a new race plan.
|
||||
func (s *PgStore) CreatePlan(ctx context.Context, p RacePlan) error {
|
||||
now := time.Now().UnixMilli()
|
||||
if p.CreatedMs == 0 {
|
||||
p.CreatedMs = now
|
||||
}
|
||||
p.UpdatedMs = now
|
||||
if p.NextFireMs == 0 {
|
||||
p.NextFireMs = p.StartAtMs
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO race_plans
|
||||
(id, name, track_id, max_cars, laps, time_limit_s,
|
||||
start_at_ms, interval_s, count,
|
||||
enabled, created_ms, updated_ms, next_fire_ms, fires_done)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
|
||||
p.ID, p.Name, p.TrackID, p.MaxCars, p.Laps, p.TimeLimitS,
|
||||
p.StartAtMs, p.IntervalS, p.Count,
|
||||
p.Enabled, p.CreatedMs, p.UpdatedMs, p.NextFireMs, p.FiresDone)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return fmt.Errorf("%w: plan %s", ErrAlreadyExists, p.ID)
|
||||
}
|
||||
return fmt.Errorf("insert plan: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPlan loads a plan by id.
|
||||
func (s *PgStore) GetPlan(ctx context.Context, id string) (RacePlan, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, name, track_id, max_cars, laps, time_limit_s,
|
||||
start_at_ms, interval_s, count,
|
||||
enabled, created_ms, updated_ms, next_fire_ms, fires_done
|
||||
FROM race_plans WHERE id = $1`, id)
|
||||
var p RacePlan
|
||||
if err := row.Scan(
|
||||
&p.ID, &p.Name, &p.TrackID, &p.MaxCars, &p.Laps, &p.TimeLimitS,
|
||||
&p.StartAtMs, &p.IntervalS, &p.Count,
|
||||
&p.Enabled, &p.CreatedMs, &p.UpdatedMs, &p.NextFireMs, &p.FiresDone,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return RacePlan{}, ErrNotFound
|
||||
}
|
||||
return RacePlan{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ListPlans returns plans in ascending start order, keyset-paginated.
|
||||
func (s *PgStore) ListPlans(ctx context.Context, cur Cursor, limit int) ([]RacePlan, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
args := []any{}
|
||||
conds := []string{"1=1"}
|
||||
if !cur.IsZero() {
|
||||
args = append(args, cur.Ms, cur.ID)
|
||||
conds = append(conds, fmt.Sprintf("(start_at_ms, id) > ($%d, $%d)", len(args)-1, len(args)))
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
q := `SELECT id, name, track_id, max_cars, laps, time_limit_s,
|
||||
start_at_ms, interval_s, count,
|
||||
enabled, created_ms, updated_ms, next_fire_ms, fires_done
|
||||
FROM race_plans
|
||||
WHERE ` + joinAnd(conds) + `
|
||||
ORDER BY start_at_ms ASC, id ASC
|
||||
LIMIT $` + fmt.Sprintf("%d", len(args))
|
||||
rows, err := s.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list plans: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanPlans(rows)
|
||||
}
|
||||
|
||||
func scanPlans(rows pgx.Rows) ([]RacePlan, error) {
|
||||
out := make([]RacePlan, 0)
|
||||
for rows.Next() {
|
||||
var p RacePlan
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.Name, &p.TrackID, &p.MaxCars, &p.Laps, &p.TimeLimitS,
|
||||
&p.StartAtMs, &p.IntervalS, &p.Count,
|
||||
&p.Enabled, &p.CreatedMs, &p.UpdatedMs, &p.NextFireMs, &p.FiresDone,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeletePlan removes a plan by id.
|
||||
func (s *PgStore) DeletePlan(ctx context.Context, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM race_plans WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete plan: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPlanEnabled toggles the enabled flag.
|
||||
func (s *PgStore) SetPlanEnabled(ctx context.Context, id string, enabled bool) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE race_plans SET enabled = $2, updated_ms = $3 WHERE id = $1`,
|
||||
id, enabled, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdvancePlan moves the next_fire_ms cursor after a successful materialise
|
||||
// and increments fires_done. If count is reached, disables the plan.
|
||||
func (s *PgStore) AdvancePlan(ctx context.Context, id string, nextFireMs int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE race_plans
|
||||
SET next_fire_ms = $2,
|
||||
fires_done = fires_done + 1,
|
||||
updated_ms = $3,
|
||||
enabled = CASE
|
||||
WHEN count > 0 AND fires_done + 1 >= count THEN FALSE
|
||||
ELSE enabled
|
||||
END
|
||||
WHERE id = $1`,
|
||||
id, nextFireMs, time.Now().UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
// DuePlans returns plans whose next_fire_ms is <= now.
|
||||
func (s *PgStore) DuePlans(ctx context.Context, nowMs int64, limit int) ([]RacePlan, error) {
|
||||
if limit <= 0 {
|
||||
limit = 16
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, name, track_id, max_cars, laps, time_limit_s,
|
||||
start_at_ms, interval_s, count,
|
||||
enabled, created_ms, updated_ms, next_fire_ms, fires_done
|
||||
FROM race_plans
|
||||
WHERE enabled AND next_fire_ms <= $1
|
||||
ORDER BY next_fire_ms ASC
|
||||
LIMIT $2`, nowMs, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanPlans(rows)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// queue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Enqueue adds (driver, race) to the queue. Idempotent: returns the existing
|
||||
// entry if present.
|
||||
func (s *PgStore) Enqueue(ctx context.Context, driverID, raceID, planID string) (QueueEntry, error) {
|
||||
if driverID == "" || raceID == "" {
|
||||
return QueueEntry{}, fmt.Errorf("%w: driver_id and race_id required", ErrInvalidInput)
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO race_queue (driver_id, race_id, plan_id, enqueued_ms)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (driver_id, race_id) DO NOTHING`,
|
||||
driverID, raceID, planID, now)
|
||||
if err != nil {
|
||||
return QueueEntry{}, fmt.Errorf("enqueue: %w", err)
|
||||
}
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT driver_id, race_id, plan_id, enqueued_ms
|
||||
FROM race_queue WHERE driver_id = $1 AND race_id = $2`,
|
||||
driverID, raceID)
|
||||
var q QueueEntry
|
||||
if err := row.Scan(&q.DriverID, &q.RaceID, &q.PlanID, &q.EnqueuedMs); err != nil {
|
||||
return QueueEntry{}, err
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// Dequeue removes (driver, race) entry.
|
||||
func (s *PgStore) Dequeue(ctx context.Context, driverID, raceID string) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM race_queue WHERE driver_id = $1 AND race_id = $2`,
|
||||
driverID, raceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListQueueByDriver returns queue entries for a driver.
|
||||
func (s *PgStore) ListQueueByDriver(ctx context.Context, driverID string) ([]QueueEntry, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT driver_id, race_id, plan_id, enqueued_ms
|
||||
FROM race_queue WHERE driver_id = $1
|
||||
ORDER BY enqueued_ms ASC`, driverID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]QueueEntry, 0)
|
||||
for rows.Next() {
|
||||
var q QueueEntry
|
||||
if err := rows.Scan(&q.DriverID, &q.RaceID, &q.PlanID, &q.EnqueuedMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListQueueByRace returns all queue entries for a given race, oldest first.
|
||||
func (s *PgStore) ListQueueByRace(ctx context.Context, raceID string) ([]QueueEntry, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT driver_id, race_id, plan_id, enqueued_ms
|
||||
FROM race_queue WHERE race_id = $1
|
||||
ORDER BY enqueued_ms ASC`, raceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]QueueEntry, 0)
|
||||
for rows.Next() {
|
||||
var q QueueEntry
|
||||
if err := rows.Scan(&q.DriverID, &q.RaceID, &q.PlanID, &q.EnqueuedMs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountQueueByRace returns the queue length for a race.
|
||||
func (s *PgStore) CountQueueByRace(ctx context.Context, raceID string) (int, error) {
|
||||
var n int
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM race_queue WHERE race_id = $1`, raceID)
|
||||
if err := row.Scan(&n); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ToLobbyMeta converts a finished race to a wire-friendly RaceMeta for
|
||||
// uniform presentation alongside live races. Podium is dropped here;
|
||||
// the wire type carries the full PodiumEntry list separately.
|
||||
func (f FinishedRace) ToLobbyMeta() lobby.RaceMeta {
|
||||
return lobby.RaceMeta{
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
TrackID: f.TrackID,
|
||||
MaxCars: f.MaxCars,
|
||||
Laps: f.Laps,
|
||||
TimeLimitS: f.TimeLimitS,
|
||||
DriverIDs: append([]string(nil), f.DriverIDs...),
|
||||
Status: lobby.RaceStatus(f.Status),
|
||||
CreatedMs: f.CreatedMs,
|
||||
StartedMs: f.StartedMs,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Package races owns the cross-cutting "race list" surface: keyset-paginated
|
||||
// listing of live and finished races, upcoming (next-N) selection, the
|
||||
// driver-to-race queue and the recurring race-plan scheduler.
|
||||
//
|
||||
// The package composes two storage backends:
|
||||
//
|
||||
// - lobby.Service (in-memory): authoritative source for active races
|
||||
// (status = lobby | countdown | racing). Used directly for the live
|
||||
// page of /api/races, for the upcoming view and for the scheduler
|
||||
// materialisation.
|
||||
// - Postgres (pgxpool): authoritative for finished races (snapshotted
|
||||
// on SetRaceStatus(finished)), for race plans and for the queue.
|
||||
// Finished races are read with keyset pagination on
|
||||
// (finished_ms DESC, id DESC).
|
||||
package races
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Errors returned by the service.
|
||||
var (
|
||||
ErrNotFound = errors.New("race or plan not found")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrAlreadyExists = errors.New("already exists")
|
||||
ErrQueueDuplicate = errors.New("driver already in queue for this race")
|
||||
)
|
||||
|
||||
// StatusFilter enumerates the high-level status groups the API understands.
|
||||
type StatusFilter string
|
||||
|
||||
const (
|
||||
StatusAll StatusFilter = "all"
|
||||
StatusFinished StatusFilter = "finished" // finished only
|
||||
StatusRacing StatusFilter = "racing" // racing only
|
||||
StatusLobby StatusFilter = "lobby" // lobby only
|
||||
)
|
||||
|
||||
// ParseStatusFilter accepts a comma-separated list of statuses
|
||||
// (e.g. "racing,finished") and returns the canonical filters. Empty
|
||||
// input means StatusAll.
|
||||
func ParseStatusFilter(s string) ([]StatusFilter, error) {
|
||||
if s == "" {
|
||||
return []StatusFilter{StatusAll}, nil
|
||||
}
|
||||
out := make([]StatusFilter, 0, 2)
|
||||
cur := ""
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c == ',' {
|
||||
tok := statusFilterTrim(cur)
|
||||
if tok == "" {
|
||||
cur = ""
|
||||
continue
|
||||
}
|
||||
f, err := parseSingleStatus(tok)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
cur = ""
|
||||
continue
|
||||
}
|
||||
cur += string(c)
|
||||
}
|
||||
tok := statusFilterTrim(cur)
|
||||
if tok != "" {
|
||||
f, err := parseSingleStatus(tok)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []StatusFilter{StatusAll}, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func statusFilterTrim(s string) string {
|
||||
// Trim spaces.
|
||||
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
|
||||
s = s[1:]
|
||||
}
|
||||
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseSingleStatus(s string) (StatusFilter, error) {
|
||||
switch s {
|
||||
case "all":
|
||||
return StatusAll, nil
|
||||
case "finished":
|
||||
return StatusFinished, nil
|
||||
case "racing":
|
||||
return StatusRacing, nil
|
||||
case "lobby":
|
||||
return StatusLobby, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: unknown status %q", ErrInvalidInput, s)
|
||||
}
|
||||
}
|
||||
|
||||
// matches returns true if the live lobby status passes the filter.
|
||||
func (f StatusFilter) matchesLive(liveStatus string) bool {
|
||||
switch f {
|
||||
case StatusAll:
|
||||
return true
|
||||
case StatusRacing:
|
||||
return liveStatus == "racing"
|
||||
case StatusLobby:
|
||||
return liveStatus == "lobby"
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user