Skip to content

Repository files navigation

Oasis Race Control

Event build (2026-09-27): Download the rig agent event build (OasisRigAgent-event.zip). Walk-up sign-in on the rig: type your name and PIN, drive, press Enter to log out. Reads laps from iRacing automatically. Unzip it, put that rig's agent.config.json beside OasisRigAgent.exe, and run OasisRigAgent.exe. OasisRigAgent.exe --diagnose still runs the read-only iRacing check.

In-store driver check-in, live timing, lap history, leaderboard, and weekly league platform for Oasis Sim Racing — a venue with ~20–25 Windows iRacing simulators.

Customer flow: scan the rig's QR code → confirm check-in on your phone → drive. Laps are captured automatically, attributed to the checked-in driver, and shown on the driver's phone, the staff dashboard, and the front-of-store TV leaderboard (Oasis Live Timing).

League night

The Wednesday in-house league. Staff open a round from /staff against one track/car combo; every lap driven on that combo while the round is open belongs to that round, so drivers just check in and drive as usual. Rounds roll up into a season, and a season is a calendar month.

  • /league — season standings across every round, with each driver's per-round breakdown and a strip of rounds to tap into. Open rounds are included, so the board moves while the night is running.
  • /league/[roundId] — one round's full field, placed by the race's finishing order with the fastest valid qualifier marked when the round has a race result, and by fastest valid lap when it has none (every round played before race results were recorded); tap a driver to expand all of their laps. Phone-first, this is the post-race comparison.
  • /tv — the front-of-store TV carries a league standings board in its rotation, and while a round is open that board takes the screen over: league night owns the wall, the arcade boards have it the rest of the week. Nobody has to take the kiosk off rotation.
  • /tv?event=1 - the event view for a laptop at an off-site event: one leaderboard of every driver with a lap today in the featured combo, scrolling through itself, with no rotation to other boards. /tv itself is unchanged. It can be scrolled by hand on a touch screen, with a wheel, or by dragging it with the mouse (what a touch display on a Mac sends), and goes back to scrolling itself after twenty seconds untouched. &host=cadillac puts the event host's logo lockup, crest and wordmark, in the footer (apps/web/src/lib/tv-host-logo.ts). Its corner QR code opens the Oasis Sim Racing website rather than the phone leaderboard, so the public at an event never lands on a page whose menu reaches the staff login; /tv keeps the leaderboard code. For the same reason the event view shows no Screens menu. On both it and the wall's Fastest tonight board, a time with an asterisk is a lap that had an incident (any iRacing incident); there is no legend, by the owner's choice. Only valid laps rank, and validity is judged once, when the lap arrives, against the featured combo's incident_limit at that moment (computeValidity), so an incident lap is on the board only if the limit admitted it then; changing the limit later neither ranks nor removes laps already stored. /staff writes 0.
  • /staff — open a round against a combo, review and correct its race result, close it when the night is over, and at the turn of the month end the season and start the next one (named for the month, in one step, refused while a round is still open).

Opening a round also sets that day's featured combo to the round's combo, because lap validity is judged against the featured combo when a lap is ingested; closing the round puts the previous combo back. Laps already logged keep the validity they were given.

Repository layout

apps/web/            # Next.js — driver portal, staff dashboard, TV leaderboard, API (Phase 2)
apps/rig-agent/      # .NET 8 Windows agent that runs on every simulator (Phase 2)
packages/shared/     # Event schemas and shared types (Phase 2)
db/                  # SQL migrations + dev seed (Postgres — Neon in prod)
deploy/              # Container image + Kustomize manifests + the local `kind` workflow
spike/               # Phase 1 throwaway telemetry recorder — proves iRacing SDK ground truth
docs/                # Plan, architecture, ops runbooks, and measured results

Status

  • Phase 0 (venue safety gate): lifted by the project owner on 2026-09-26 ("disregard that rule we are past that. We need this to run"). docs/venue-safety.md keeps the guidance (read-only iRacing access, no elevation, bounded logging) as recommendations and as the checklist for a signed release; it no longer blocks running the agent on Oasis computers.
  • Phase 1 (Oasis canary + iRacing spike): superseded by the agent's own --diagnose run on the owner's rig (2026-09-26). The laps table and agent event contract remain provisional until an approved canary and recording session complete. See docs/spike-checklist.md, docs/spike-findings.md, and spike/.
  • Phase 2 (web/API slice and rig agent): built. Check-in, driver portal, TV leaderboard, staff dashboard, league night, ingestion API, fake-rig simulator, and the agent's outbox, attribution and quarantine all work. The agent also reads laps from iRacing's shared memory ("telemetry": "iracing"); its lap detection is verified against real iRacing on the owner's rig with OasisRigAgent.exe --diagnose (2026-09-26, test drive, FIA F4 at COTA Grand Prix), and that read-only diagnostic is the first check on any rig; posting laps to the hosted app from a rig is not yet verified (apps/rig-agent/README.md). The Windows agent UI remains a console.

Web app development

The database is plain Postgres — Neon in production, any local Postgres in dev. All access is server-side (docs/plan.md has the access paths); there is no realtime service (the TV and portal poll every few seconds, which is indistinguishable from push at venue scale).

One-time setup:

  1. Database:
    • Local: docker run -d --name oasis-pg -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=oasis -p 5433:5432 postgres:16
    • Neon: create a project at neon.tech and copy the pooled connection string (the -pooler host, with sslmode=require).
  2. Copy apps/web/.env.example to apps/web/.env.local and fill in DATABASE_URL and SESSION_SECRET.
  3. Apply schema + dev data: npm run db:migrate && npm run db:seed (from apps/web). Seed data: rigs 1–3, QR tokens demo-rig-1..3, drivers with PIN 1234, staff login staff@oasis.test / oasis-staff-demo, tonight's featured combo. The seed is for local/demo environments only — its rig tokens, QR slugs, PINs, and staff password are deliberately guessable. In production, run only db:migrate, enroll rigs with random tokens (openssl rand -hex 32), and insert real staff rows with strong bcrypt-hashed passwords - no shorter than MIN_STAFF_PASSWORD_LENGTH in apps/web/src/lib/staff-login-refusal.ts, which sign-in enforces and nothing checks when the row is written.
  4. Vercel: import this repo, set root directory to apps/web, add the same two env vars (use the Neon pooled URL).

Daily loop:

cd apps/web
npm run dev        # http://localhost:3000
npm run fake-rig   # simulates Rig 01 sending heartbeats + laps (needs dev seed)
npx tsx scripts/manual-lap.ts --token <rig token> --base <app url> --time 2:32.340   # fallback when a rig cannot read iRacing: post one lap by hand, in today's featured combo, for whoever is checked in on that rig
npm test           # unit tests, plus the league lifecycle suite when a local database is reachable
npm run db:check   # read-only: is DATABASE_URL's database behind db/migrations?
npm run db:migrate # apply any new migrations in db/migrations/

Running it on local Kubernetes

There is a kind-based local cluster that runs the web app the way a real cluster would — two replicas behind a Service, liveness/readiness probes, a rolling-update strategy, a development-only Postgres, and the fake rig feeding it laps:

./deploy/local/oasis-kind.sh up   # then open http://localhost:8080

It is for development and demonstration only and deploys nothing — production is still Vercel plus Neon. Full walkthrough: docs/platform/local-kubernetes.md.

Integration tests

npm test covers pure logic, the API routes' auth/validation branches, and components rendered to markup (*.test.tsx - no DOM shim, so a component test asserts on the HTML string; see apps/web/vitest.config.ts). The guarantees that live in Postgres - the event_id idempotency key, the one-open-assignment-per-rig/driver partial unique indexes, the checkin_driver() function, the sign-out that closes only the stint it names and only on the calling rig, the check constraints that keep an unattributed lap unrankable and make it say why, every rig heartbeat (v1 or v2) kept as a row with its clock skew worked out by the database, the rig monitor's alerts firing once and recovering once however many evaluations run at once, event mode and its once-per-flip line and 20-minute update, the TV board heartbeat's one row per open page, the live race feed's one row per rig and its grouping, order, staleness and driver join, league night's race result (captured at the flag, swept at close, frozen by a staff correction) and the points it scores, the upgrade path of a migration onto a database that already holds laps, and the staff PIN reset judged through the driver sign-in it repairs (new PIN in, old PIN out, lockout gone, laps kept, audit row written) - are covered by a separate suite that needs a real database:

docker start oasis-pg   # or: docker run -d --name oasis-pg -e POSTGRES_PASSWORD=postgres -p 5433:5432 postgres:16
docker exec oasis-pg psql -U postgres -c 'create database oasis_test'

export TEST_DATABASE_URL="postgres://postgres:postgres@localhost:5433/oasis_test"
npm run test:integration

The suite skips (it does not fail) when TEST_DATABASE_URL is unset, so npm test and CI stay green without Postgres. It reads only TEST_DATABASE_URL - never DATABASE_URL - because these tests truncate every table and .env.local normally points at live Neon. The URL must be a local host with test in the database name; managed hosts are refused outright before any connection is opened (src/test/db-guard.ts). Migrations are reapplied from scratch on each run, so a schema change can never leave the test database stale.

One real-database suite runs under plain npm test rather than test:integration: src/lib/league-round-lifecycle.test.ts, which covers league night's effect on the rest of the venue day and the round/season concurrency rules. It builds its own throwaway database from db/migrations and drops it afterwards, so it never truncates anything you already have. It prefers TEST_DATABASE_URL and otherwise falls back to a local DATABASE_URL, rewriting either to that scratch database. Both paths go through the same db-guard.ts refusals - managed hosts, non-local hosts, and redirecting connection parameters - applied to the scratch URL before any connection opens. An explicit but unsafe TEST_DATABASE_URL is a hard error; an unusable DATABASE_URL just skips the suite, which is why it is quiet on a machine pointed at Neon. Nothing loads .env.local for tests, so give it a URL:

TEST_DATABASE_URL="postgres://postgres:postgres@localhost:5433/oasis_test" npm test

Neither suite says anything about the venue's width. That measurement is a separate twenty-rig ingestion soak; its runbook and committed result are in docs/soak-20-rigs.md.

Nor does either look at the wall. npm run tv:check (from apps/web, against a running server) opens /tv in the machine's own Google Chrome through playwright-core, screenshots it, and fails if the QR code in the corner is clipped or overlaps a board row, the board header, or the rest of the footer - then waits for the rotation to move and checks the next board too. --viewport 1272x601 is the venue wall and the default; pass a laptop size to see what an off-site screen shows, and --url http://localhost:3000/tv?event=1 to check the event view (one board, so it does not wait for a second). It also fails if the app's Screens button is shown on the event view, or hidden on the rotation, and if the corner code does not decode to the view's target: the Oasis website on the event view, the page's own /leaderboards on the rotation.

npm run tv:scroll-check does the same kind of thing for the event view's hand scrolling: against /tv?event=1 it swipes the list with a finger, turns the wheel over it, drags it with the mouse (what a touch display on a Mac sends) and clicks it, and fails unless each takes the list over, moves it the right way (a click moves nothing), and gives it back to the automatic scroll twenty seconds after the last interaction - not while a mouse button is still held on it. The featured combo needs enough laps today for the list to overflow the screen. It takes a little over two minutes.

npm run tv:heartbeat-check proves the board's heartbeat to the rig monitor (docs/monitoring.md): the public /tv and /tv?event=1 send nothing, the event board opened from the staff link heartbeats every 30 seconds, closing its tab sends a goodbye and raises no alert, and killing the browser raises exactly one "board went dark" alert three minutes later. It signs the staff link itself, reads the server's database and calls the monitor tick, so run the server as a production build with SESSION_SECRET and CRON_SECRET, no DISCORD_WEBHOOK_URL, and a throwaway DATABASE_URL, and give the script the same three. About eight minutes.

npm run leaderboards:check does the same for the page the shop rotation's code opens: it loads /leaderboards at 390x844 (--viewport for another phone) with long driver names of its own in place of the board's rows, and fails if any name is truncated, the page scrolls sideways, anything covers the LEADERBOARDS heading, or the Screens button is missing. The server needs at least one lap so there is a board to show.

Demo: open /r/demo-rig-1 on your phone (or localhost), check in as a guest, start npm run fake-rig, and watch laps land on /me and /tv. Check in first: like the real agent, the fake rig polls GET /api/agent/assignment and stamps each lap with the assignment that was open when it was driven, and the ingestion API attributes from that stamp rather than crediting the lap to whoever is checked in when it arrives (docs/plan.md, event model). Laps driven before you check in are stored unattributed - kept, unrankable, and listed on /staff under Unclaimed laps. They are never backfilled onto you once you do check in. Staff dashboard is at /staff. To try league night, open a round from /staff against the combo the fake rig drives, then watch /league and the round's page fill up.

Building an unsigned spike test candidate

This produces an off-site test artifact only. Do not take a locally built or unsigned executable to Oasis. Venue candidates must come from the protected spike-v* signing workflow and complete every gate in docs/venue-safety.md.

export PATH="$HOME/.dotnet:$PATH"
dotnet test spike/OasisSpike.sln -c Release
dotnet publish spike/OasisSpike/OasisSpike.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true

The venue-facing interface has no default run mode: --mode canary enforces 10 minutes/25 MiB and --mode full enforces 120 minutes/100 MiB. Never inspect or edit iRacing configuration during the canary. A failure to connect is a stop-and-reschedule result.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages