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.jsonbesideOasisRigAgent.exe, and runOasisRigAgent.exe.OasisRigAgent.exe --diagnosestill 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).
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./tvitself 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=cadillacputs 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;/tvkeeps 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'sincident_limitat 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./staffwrites 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.
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
- 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.mdkeeps 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
--diagnoserun on the owner's rig (2026-09-26). Thelapstable and agent event contract remain provisional until an approved canary and recording session complete. Seedocs/spike-checklist.md,docs/spike-findings.md, andspike/. - 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 withOasisRigAgent.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.
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:
- 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
-poolerhost, withsslmode=require).
- Local:
- Copy
apps/web/.env.exampletoapps/web/.env.localand fill inDATABASE_URLandSESSION_SECRET. - Apply schema + dev data:
npm run db:migrate && npm run db:seed(fromapps/web). Seed data: rigs 1–3, QR tokensdemo-rig-1..3, drivers with PIN 1234, staff loginstaff@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 onlydb:migrate, enroll rigs with random tokens (openssl rand -hex 32), and insert real staff rows with strong bcrypt-hashed passwords - no shorter thanMIN_STAFF_PASSWORD_LENGTHinapps/web/src/lib/staff-login-refusal.ts, which sign-in enforces and nothing checks when the row is written. - 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/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:8080It is for development and demonstration only and deploys nothing — production is still Vercel plus Neon. Full walkthrough: docs/platform/local-kubernetes.md.
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:integrationThe 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 testNeither 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.
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=trueThe 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.