+ {/each}
+
+
+
diff --git a/frontend/packages/components/src/RaceClock.svelte b/frontend/packages/components/src/RaceClock.svelte
new file mode 100644
index 0000000..59232b8
--- /dev/null
+++ b/frontend/packages/components/src/RaceClock.svelte
@@ -0,0 +1,24 @@
+
+
+{label}
+
+
diff --git a/frontend/packages/components/src/index.ts b/frontend/packages/components/src/index.ts
new file mode 100644
index 0000000..7ebb4e4
--- /dev/null
+++ b/frontend/packages/components/src/index.ts
@@ -0,0 +1,10 @@
+/**
+ * @gridfpv/components — shared GridFPV Svelte 5 component library.
+ *
+ * Race-domain widgets built once and themed per surface (RD console, spectator
+ * PWA, OBS overlays), per docs/clients.html §3. Placeholder set for now; later
+ * issues flesh out the real leaderboard, bracket tree, heat sheet, pilot card,
+ * standings table, etc.
+ */
+export { default as Leaderboard } from './Leaderboard.svelte';
+export { default as RaceClock } from './RaceClock.svelte';
diff --git a/frontend/packages/components/svelte.config.js b/frontend/packages/components/svelte.config.js
new file mode 100644
index 0000000..21b9399
--- /dev/null
+++ b/frontend/packages/components/svelte.config.js
@@ -0,0 +1,5 @@
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+export default {
+ preprocess: vitePreprocess()
+};
diff --git a/frontend/packages/components/tsconfig.json b/frontend/packages/components/tsconfig.json
new file mode 100644
index 0000000..f065488
--- /dev/null
+++ b/frontend/packages/components/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": ["svelte"]
+ },
+ "include": ["src"]
+}
diff --git a/frontend/packages/protocol-client/package.json b/frontend/packages/protocol-client/package.json
new file mode 100644
index 0000000..97f2205
--- /dev/null
+++ b/frontend/packages/protocol-client/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@gridfpv/protocol-client",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Thin, framework-agnostic protocol client: connect to a base URL, snapshot + WS subscribe, typed state. STUB — filled by #49.",
+ "license": "AGPL-3.0-or-later",
+ "type": "module",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "default": "./src/index.ts"
+ }
+ },
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "check": "tsc -p tsconfig.json --noEmit"
+ },
+ "dependencies": {
+ "@gridfpv/types": "*"
+ },
+ "devDependencies": {
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/frontend/packages/protocol-client/src/index.ts b/frontend/packages/protocol-client/src/index.ts
new file mode 100644
index 0000000..b18b429
--- /dev/null
+++ b/frontend/packages/protocol-client/src/index.ts
@@ -0,0 +1,55 @@
+/**
+ * @gridfpv/protocol-client — STUB.
+ *
+ * The thin, framework-agnostic protocol layer described in docs/clients.html §3:
+ * connect to a base URL, fetch a projection snapshot, subscribe to the WebSocket
+ * change stream, and expose typed state. It is configured only with a base URL,
+ * so it cannot tell LAN from Cloud — the same client backs all three surfaces on
+ * both transports.
+ *
+ * This package currently only nails down the public surface so apps can wire
+ * against it. The real implementation (snapshot fetch, WS reconnect, typed
+ * subscriptions, auth headers) is issue #49.
+ */
+import type { RaceSnapshot } from '@gridfpv/types';
+
+/** Options for {@link connect}. Expanded by #49 (auth token, transports, etc.). */
+export interface ConnectOptions {
+ /**
+ * Base URL of the Director (or Cloud) protocol server, e.g.
+ * `http://director.local:8080` or `https://cloud.gridfpv.example`.
+ */
+ baseUrl: string;
+}
+
+/**
+ * A live connection to the protocol server. The shape here is a placeholder; #49
+ * defines the real snapshot/subscribe/typed-state API.
+ */
+export interface ProtocolClient {
+ readonly baseUrl: string;
+ /** Fetch the current projection snapshot. Implemented by #49. */
+ snapshot(): Promise;
+ /** Close the connection and tear down any WebSocket. Implemented by #49. */
+ close(): void;
+}
+
+/**
+ * Connect to a GridFPV protocol server.
+ *
+ * STUB: signature only. #49 implements snapshot + WS subscribe.
+ *
+ * @param options - connection options, or a bare base URL string for convenience.
+ */
+export function connect(options: ConnectOptions | string): ProtocolClient {
+ const baseUrl = typeof options === 'string' ? options : options.baseUrl;
+ return {
+ baseUrl,
+ snapshot(): Promise {
+ return Promise.reject(new Error('protocol-client: connect() is a stub — implemented by #49'));
+ },
+ close(): void {
+ /* no-op until #49 */
+ }
+ };
+}
diff --git a/frontend/packages/protocol-client/tsconfig.json b/frontend/packages/protocol-client/tsconfig.json
new file mode 100644
index 0000000..5a24989
--- /dev/null
+++ b/frontend/packages/protocol-client/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src"]
+}
diff --git a/frontend/packages/types/package.json b/frontend/packages/types/package.json
new file mode 100644
index 0000000..9260ee4
--- /dev/null
+++ b/frontend/packages/types/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@gridfpv/types",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Single import seam for the GridFPV protocol types generated from Rust via ts-rs (repo-root bindings/).",
+ "license": "AGPL-3.0-or-later",
+ "type": "module",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "default": "./src/index.ts"
+ }
+ },
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "check": "tsc -p tsconfig.json --noEmit"
+ },
+ "devDependencies": {
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts
new file mode 100644
index 0000000..3719902
--- /dev/null
+++ b/frontend/packages/types/src/generated.ts
@@ -0,0 +1,41 @@
+/**
+ * Adapter to the ts-rs–generated protocol bindings.
+ *
+ * The wire types are generated from the Rust server crate into the repo-root
+ * `bindings/` directory (one file per type), per docs/clients.html §3 and
+ * architecture.html §6. The frontend NEVER hand-writes a wire type — this file
+ * is the only seam that knows where the generated types physically live.
+ *
+ * ── When real bindings exist ────────────────────────────────────────────────
+ * `bindings/` is expected to expose a barrel (e.g. `bindings/index.ts`). Replace
+ * the placeholder block below with a single re-export, resolved through the
+ * `@bindings/*` tsconfig path alias:
+ *
+ * export * from '@bindings/index';
+ *
+ * (or re-export the specific generated modules you need). Nothing else in the
+ * frontend changes, because everything imports from `@gridfpv/types`.
+ *
+ * ── Standalone fallback (bindings/ absent) ──────────────────────────────────
+ * Until the Rust generation step has run — e.g. a frontend-only checkout or CI
+ * that builds the frontend in isolation — `bindings/` may not exist. To keep the
+ * monorepo buildable and type-checkable on its own, we define a minimal set of
+ * placeholder types here. These are intentionally thin and exist only so the
+ * scaffold compiles; they are replaced wholesale by the generated re-export.
+ */
+
+/** Opaque identifier for a pilot. Generated type will supersede this. */
+export type PilotId = string;
+
+/** Opaque identifier for a race/heat. Generated type will supersede this. */
+export type RaceId = string;
+
+/**
+ * Placeholder snapshot shape. The real, ts-rs–generated projection snapshot
+ * type will replace this once `bindings/` is populated.
+ */
+export interface RaceSnapshot {
+ raceId: RaceId;
+ /** Pilots in finishing/standings order. */
+ pilots: PilotId[];
+}
diff --git a/frontend/packages/types/src/index.ts b/frontend/packages/types/src/index.ts
new file mode 100644
index 0000000..e80efc8
--- /dev/null
+++ b/frontend/packages/types/src/index.ts
@@ -0,0 +1,12 @@
+/**
+ * @gridfpv/types — the single import seam for GridFPV protocol types.
+ *
+ * Every app and package imports protocol/wire types from here:
+ *
+ * import type { RaceSnapshot, PilotId } from '@gridfpv/types';
+ *
+ * The actual definitions come from the ts-rs–generated bindings (see
+ * ./generated.ts for how regenerated bindings flow in, and the standalone
+ * fallback used when `bindings/` is absent).
+ */
+export * from './generated.js';
diff --git a/frontend/packages/types/tsconfig.json b/frontend/packages/types/tsconfig.json
new file mode 100644
index 0000000..52d7761
--- /dev/null
+++ b/frontend/packages/types/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "baseUrl": ".",
+ "paths": {
+ // The generated ts-rs bindings live at the repo root. The `@bindings/*`
+ // alias is the one place that knows their physical location, so apps and
+ // packages only ever import from `@gridfpv/types`. When real bindings
+ // exist, src/generated.ts re-exports from here.
+ "@bindings/*": ["../../../bindings/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/frontend/tsconfig.base.json b/frontend/tsconfig.base.json
new file mode 100644
index 0000000..44e4ae6
--- /dev/null
+++ b/frontend/tsconfig.base.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": true,
+ "sourceMap": true
+ }
+}
From 9f005e4175e6b14d86f9b72528502b18e82a624e Mon Sep 17 00:00:00 2001
From: "Ryan Johnson (ntninja)"
Date: Sat, 20 Jun 2026 03:43:18 +0000
Subject: [PATCH 003/362] =?UTF-8?q?Define=20protocol=20server=20wire=20typ?=
=?UTF-8?q?es=20+=20extend=20Rust=E2=86=92TS=20generation=20(#40)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fill the scaffolded `gridfpv-server` crate with the protocol wire types —
the one read/realtime contract, defined once in Rust and generated to
TypeScript (protocol.html §6). No transport yet (axum is #42+): this is
the type surface every later issue (#42–#45 endpoints/stream/auth/control,
#49 frontend client) builds against.
Wire types (crates/server/src/):
- lib.rs: `ContractVersion` (§7) + `Hello`/`ServerHello` connect handshake;
`CONTRACT_VERSION = 1`.
- error.rs: the single shared `ProtocolError { code, message }` + `ErrorCode`
(§9.8) used across HTTP reads, the WS stream, and control acks.
- scope.rs: `Scope` (Event/Class/Heat/Pilot, §4) with `EventId`/`ClassId`/
`PilotId` newtypes; `SubscribeRequest { scope, from: Option }`.
- stream.rs: `Cursor` (the per-stream monotonic sequence, §3/§9.5);
`ChangeEnvelope { sequence, projection, change }` with
`Change { Delta | FreshValue }` — the per-projection delta-vs-fresh
distinction (§9.2).
- snapshot.rs: `Snapshot { cursor, body }`; `ProjectionBody`/`ProjectionKind`
over the existing projection/engine types + a minimal-but-real
`LiveRaceState`/`HeatPhase` placeholder for #41.
- control.rs: `Command` (Stage/Arm/Start/Finish/Score/Advance/Abort/Restart/
Discard, ScheduleHeat, Register, + the 5 marshaling adjudications) and
`CommandAck { ok, error }` (§5).
ts-rs export: add `#[derive(TS)] #[ts(export, export_to="bindings/")]` (and
serde where missing) to the served projection/engine output types —
`CompetitorKey`/`Lap`/`CompetitorLaps`/`LapList` (projection) and
`WinCondition`/`Placement`/`Metric`/`HeatResult`/`RankEntry`/`CompletedHeat`/
`EventOutcome` (engine) — so the snapshot/envelope embed generated types.
Add `ts-rs` (+ `serde` for engine) to those crates' manifests.
xtask: `gen_bindings` now runs `cargo test --workspace export_bindings` so
events, projection, engine, and server all export, `TS_RS_EXPORT_DIR` still
pinned to the repo root. Drift check passes with the new bindings/*.ts.
Deferred (noted in-code): exact per-projection delta encodings (#43, Change::
Delta is a serde_json::Value placeholder), the full scope addressing grammar
(#42/§9.6), LiveRaceState detail (#41), and auth token format (#44).
Unit tests: serde round-trips for snapshot, change envelope, command, error,
and the handshake types. `cargo xtask ci` green (fmt, clippy -D warnings,
tests, gen drift check).
Part of #40.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
Cargo.lock | 3 +
bindings/Change.ts | 19 ++
bindings/ChangeEnvelope.ts | 29 ++++
bindings/ClassId.ts | 7 +
bindings/Command.ts | 128 ++++++++++++++
bindings/CommandAck.ts | 23 +++
bindings/CompetitorKey.ts | 22 +++
bindings/CompetitorLaps.ts | 16 ++
bindings/CompletedHeat.ts | 21 +++
bindings/ContractVersion.ts | 14 ++
bindings/Cursor.ts | 17 ++
bindings/ErrorCode.ts | 12 ++
bindings/EventId.ts | 10 ++
bindings/EventOutcome.ts | 30 ++++
bindings/HeatPhase.ts | 13 ++
bindings/HeatResult.ts | 15 ++
bindings/Hello.ts | 18 ++
bindings/Lap.ts | 15 ++
bindings/LapList.ts | 14 ++
bindings/LiveRaceState.ts | 24 +++
bindings/Metric.ts | 8 +
bindings/PilotId.ts | 12 ++
bindings/Placement.ts | 31 ++++
bindings/ProjectionBody.ts | 18 ++
bindings/ProjectionKind.ts | 12 ++
bindings/ProtocolError.ts | 19 ++
bindings/RankEntry.ts | 21 +++
bindings/Scope.ts | 41 +++++
bindings/ServerHello.ts | 26 +++
bindings/Snapshot.ts | 23 +++
bindings/SubscribeRequest.ts | 24 +++
bindings/WinCondition.ts | 19 ++
crates/engine/Cargo.toml | 4 +
crates/engine/src/event.rs | 5 +-
crates/engine/src/format.rs | 8 +-
crates/engine/src/scoring.rs | 14 +-
crates/projection/Cargo.toml | 3 +
crates/projection/src/lib.rs | 13 +-
crates/server/src/control.rs | 316 ++++++++++++++++++++++++++++++++++
crates/server/src/error.rs | 102 +++++++++++
crates/server/src/lib.rs | 154 +++++++++++++++++
crates/server/src/scope.rs | 164 ++++++++++++++++++
crates/server/src/snapshot.rs | 215 +++++++++++++++++++++++
crates/server/src/stream.rs | 142 +++++++++++++++
xtask/src/main.rs | 21 ++-
45 files changed, 1843 insertions(+), 22 deletions(-)
create mode 100644 bindings/Change.ts
create mode 100644 bindings/ChangeEnvelope.ts
create mode 100644 bindings/ClassId.ts
create mode 100644 bindings/Command.ts
create mode 100644 bindings/CommandAck.ts
create mode 100644 bindings/CompetitorKey.ts
create mode 100644 bindings/CompetitorLaps.ts
create mode 100644 bindings/CompletedHeat.ts
create mode 100644 bindings/ContractVersion.ts
create mode 100644 bindings/Cursor.ts
create mode 100644 bindings/ErrorCode.ts
create mode 100644 bindings/EventId.ts
create mode 100644 bindings/EventOutcome.ts
create mode 100644 bindings/HeatPhase.ts
create mode 100644 bindings/HeatResult.ts
create mode 100644 bindings/Hello.ts
create mode 100644 bindings/Lap.ts
create mode 100644 bindings/LapList.ts
create mode 100644 bindings/LiveRaceState.ts
create mode 100644 bindings/Metric.ts
create mode 100644 bindings/PilotId.ts
create mode 100644 bindings/Placement.ts
create mode 100644 bindings/ProjectionBody.ts
create mode 100644 bindings/ProjectionKind.ts
create mode 100644 bindings/ProtocolError.ts
create mode 100644 bindings/RankEntry.ts
create mode 100644 bindings/Scope.ts
create mode 100644 bindings/ServerHello.ts
create mode 100644 bindings/Snapshot.ts
create mode 100644 bindings/SubscribeRequest.ts
create mode 100644 bindings/WinCondition.ts
create mode 100644 crates/server/src/control.rs
create mode 100644 crates/server/src/error.rs
create mode 100644 crates/server/src/scope.rs
create mode 100644 crates/server/src/snapshot.rs
create mode 100644 crates/server/src/stream.rs
diff --git a/Cargo.lock b/Cargo.lock
index b4a9c70..2731ebc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -413,7 +413,9 @@ dependencies = [
"gridfpv-events",
"gridfpv-projection",
"gridfpv-testkit",
+ "serde",
"serde_json",
+ "ts-rs",
]
[[package]]
@@ -432,6 +434,7 @@ dependencies = [
"gridfpv-events",
"serde",
"serde_json",
+ "ts-rs",
]
[[package]]
diff --git a/bindings/Change.ts b/bindings/Change.ts
new file mode 100644
index 0000000..2ae6a93
--- /dev/null
+++ b/bindings/Change.ts
@@ -0,0 +1,19 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { ProjectionBody } from "./ProjectionBody";
+
+/**
+ * How an envelope conveys a projection change (protocol.html §3, §9.2): a **delta**
+ * for append-heavy projections (lap lists, live state) or a **fresh value** for cheap
+ * or re-folded ones (a heat result, a ranking after a marshaling correction).
+ *
+ * Externally tagged, mapping to a TS discriminated union.
+ *
+ * > **Deferred (#43):** the exact per-projection delta *encodings* are a placeholder.
+ * > [`Change::Delta`] carries a `serde_json::Value` rather than a typed delta for each
+ * > [`ProjectionKind`]; the precise delta shapes (a single appended lap, a heat-state
+ * > transition) are pinned when the change stream lands. The *structure* — the
+ * > per-projection delta-vs-fresh-value distinction protocol.html §9.2 fixes — is
+ * > what matters now and is captured here. A fresh value, by contrast, is already a
+ * > fully-typed [`ProjectionBody`].
+ */
+export type Change = { "Delta": unknown } | { "FreshValue": ProjectionBody };
diff --git a/bindings/ChangeEnvelope.ts b/bindings/ChangeEnvelope.ts
new file mode 100644
index 0000000..83233a8
--- /dev/null
+++ b/bindings/ChangeEnvelope.ts
@@ -0,0 +1,29 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { Change } from "./Change";
+import type { Cursor } from "./Cursor";
+import type { ProjectionKind } from "./ProjectionKind";
+
+/**
+ * A change envelope (protocol.html §3): one ordered, sequenced update to a single
+ * projection.
+ *
+ * Each envelope carries its monotonic `sequence` [`Cursor`], names the `projection` it
+ * touches (as a [`ProjectionKind`] — for a [`Change::FreshValue`] the body also
+ * carries the kind, but naming it here keeps deltas and fresh values uniform), and a
+ * `change` that is a delta or a fresh value. The client applies envelopes in strictly
+ * increasing `sequence` order; re-delivering one already applied is a no-op keyed by
+ * sequence (§3 "idempotent application", "at-least-once, deduplicated").
+ */
+export type ChangeEnvelope = {
+/**
+ * This envelope's position in the per-stream sequence.
+ */
+sequence: Cursor,
+/**
+ * Which projection this change advances.
+ */
+projection: ProjectionKind,
+/**
+ * The delta or fresh value to apply.
+ */
+change: Change, };
diff --git a/bindings/ClassId.ts b/bindings/ClassId.ts
new file mode 100644
index 0000000..d4a4685
--- /dev/null
+++ b/bindings/ClassId.ts
@@ -0,0 +1,7 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Identifies a **class** within an event (protocol.html §4 "Class scope") — one
+ * class's phases, schedule, and standings, which may run in parallel with others.
+ */
+export type ClassId = string;
diff --git a/bindings/Command.ts b/bindings/Command.ts
new file mode 100644
index 0000000..1fc4df9
--- /dev/null
+++ b/bindings/Command.ts
@@ -0,0 +1,128 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { AdapterId } from "./AdapterId";
+import type { CompetitorRef } from "./CompetitorRef";
+import type { HeatId } from "./HeatId";
+import type { LogRef } from "./LogRef";
+import type { Penalty } from "./Penalty";
+import type { PilotId } from "./PilotId";
+import type { SourceTime } from "./SourceTime";
+
+/**
+ * A privileged RD control command (protocol.html §5). Externally tagged like the
+ * event model, so it maps to a TS discriminated union.
+ *
+ * The variants fall into four groups:
+ *
+ * - **Heat-loop transitions** — [`Stage`](Command::Stage), [`Arm`](Command::Arm),
+ * [`Start`](Command::Start), [`Finish`](Command::Finish), [`Score`](Command::Score),
+ * [`Advance`](Command::Advance), and the off-ramps [`Abort`](Command::Abort),
+ * [`Restart`](Command::Restart), [`Discard`](Command::Discard). Each requests the
+ * matching [`HeatTransition`](gridfpv_events::HeatTransition); the engine validates
+ * it against the heat's current state (race-engine.html §2).
+ * - **Scheduling** — [`ScheduleHeat`](Command::ScheduleHeat) creates a heat with its
+ * lineup ([`Event::HeatScheduled`](gridfpv_events::Event::HeatScheduled)).
+ * - **Registration** — [`Register`](Command::Register) binds a source-local
+ * competitor to a pilot (the binding the adapter never does itself; Architecture §9).
+ * - **Marshaling adjudications** — the five corrections
+ * ([`VoidDetection`](Command::VoidDetection), [`InsertLap`](Command::InsertLap),
+ * [`AdjustLap`](Command::AdjustLap), [`VoidHeat`](Command::VoidHeat),
+ * [`ApplyPenalty`](Command::ApplyPenalty)), each requesting the corresponding
+ * marshaling event the projection folds in (never a mutation; architecture.html §3).
+ */
+export type Command = { "Stage": {
+/**
+ * The heat to transition.
+ */
+heat: HeatId, } } | { "Arm": {
+/**
+ * The heat to transition.
+ */
+heat: HeatId, } } | { "Start": {
+/**
+ * The heat to transition.
+ */
+heat: HeatId, } } | { "Finish": {
+/**
+ * The heat to transition.
+ */
+heat: HeatId, } } | { "Score": {
+/**
+ * The heat to transition.
+ */
+heat: HeatId, } } | { "Advance": {
+/**
+ * The heat to transition.
+ */
+heat: HeatId, } } | { "Abort": {
+/**
+ * The heat to transition.
+ */
+heat: HeatId, } } | { "Restart": {
+/**
+ * The heat to transition.
+ */
+heat: HeatId, } } | { "Discard": {
+/**
+ * The heat to transition.
+ */
+heat: HeatId, } } | { "ScheduleHeat": {
+/**
+ * The id the new heat will carry.
+ */
+heat: HeatId,
+/**
+ * The competitors in the heat, in lineup order.
+ */
+lineup: Array, } } | { "Register": {
+/**
+ * The timing source the competitor belongs to.
+ */
+adapter: AdapterId,
+/**
+ * The source-local competitor handle being bound.
+ */
+competitor: CompetitorRef,
+/**
+ * The event-scoped pilot the competitor is bound to.
+ */
+pilot: PilotId, } } | { "VoidDetection": {
+/**
+ * The log offset of the pass (or ruling) to void.
+ */
+target: LogRef, } } | { "InsertLap": {
+/**
+ * The timing source to attribute the inserted pass to.
+ */
+adapter: AdapterId,
+/**
+ * The competitor the inserted lap belongs to.
+ */
+competitor: CompetitorRef,
+/**
+ * When the inserted crossing happened, on the source clock.
+ */
+at: SourceTime, } } | { "AdjustLap": {
+/**
+ * The log offset of the pass to re-time.
+ */
+target: LogRef,
+/**
+ * The corrected crossing time, on the source clock.
+ */
+at: SourceTime, } } | { "VoidHeat": {
+/**
+ * The heat to void.
+ */
+heat: HeatId, } } | { "ApplyPenalty": {
+/**
+ * The heat the penalty applies in.
+ */
+heat: HeatId,
+/**
+ * The competitor penalized.
+ */
+competitor: CompetitorRef,
+/**
+ * The penalty applied.
+ */
+penalty: Penalty, } };
diff --git a/bindings/CommandAck.ts b/bindings/CommandAck.ts
new file mode 100644
index 0000000..a1c8b02
--- /dev/null
+++ b/bindings/CommandAck.ts
@@ -0,0 +1,23 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { ProtocolError } from "./ProtocolError";
+
+/**
+ * The acknowledgement of a [`Command`] (protocol.html §5): commands up,
+ * acknowledgements down.
+ *
+ * `ok` is the success flag; on failure `error` carries the single shared
+ * [`ProtocolError`](crate::error::ProtocolError) (§9.8) — an illegal transition for
+ * the heat's state, an unauthorized caller, an unknown heat. On success `error` is
+ * `None`. (The resulting projection state flows back separately as
+ * [`ChangeEnvelope`](crate::stream::ChangeEnvelope)s on the read stream, not in the
+ * ack.)
+ */
+export type CommandAck = {
+/**
+ * Whether the command was accepted and applied.
+ */
+ok: boolean,
+/**
+ * The failure detail when `ok` is `false`; `None` on success.
+ */
+error?: ProtocolError, };
diff --git a/bindings/CompetitorKey.ts b/bindings/CompetitorKey.ts
new file mode 100644
index 0000000..6fb8671
--- /dev/null
+++ b/bindings/CompetitorKey.ts
@@ -0,0 +1,22 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { AdapterId } from "./AdapterId";
+import type { CompetitorRef } from "./CompetitorRef";
+
+/**
+ * Identifies a competitor *within a single timing source*.
+ *
+ * A source-local [`CompetitorRef`] is only meaningful relative to the adapter
+ * that emitted it (node 2 on RotorHazard is unrelated to node 2 on a second
+ * timer), so laps are grouped on the `(AdapterId, CompetitorRef)` pair. Binding
+ * these per-source competitors to a single GridFPV pilot is a later registration
+ * concern (Architecture §9) and deliberately out of scope here.
+ */
+export type CompetitorKey = {
+/**
+ * The timing source the competitor belongs to.
+ */
+adapter: AdapterId,
+/**
+ * The source-local competitor handle.
+ */
+competitor: CompetitorRef, };
diff --git a/bindings/CompetitorLaps.ts b/bindings/CompetitorLaps.ts
new file mode 100644
index 0000000..8717f70
--- /dev/null
+++ b/bindings/CompetitorLaps.ts
@@ -0,0 +1,16 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { CompetitorKey } from "./CompetitorKey";
+import type { Lap } from "./Lap";
+
+/**
+ * Every lap a single competitor completed, in order.
+ */
+export type CompetitorLaps = {
+/**
+ * Which source-local competitor these laps belong to.
+ */
+competitor: CompetitorKey,
+/**
+ * Completed laps, ordered by lap number (1-based, ascending).
+ */
+laps: Array, };
diff --git a/bindings/CompletedHeat.ts b/bindings/CompletedHeat.ts
new file mode 100644
index 0000000..78a1b67
--- /dev/null
+++ b/bindings/CompletedHeat.ts
@@ -0,0 +1,21 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { HeatId } from "./HeatId";
+import type { HeatResult } from "./HeatResult";
+
+/**
+ * A scored heat fed back into the generator: the heat's id and its [`HeatResult`].
+ *
+ * This is the generator's *only* input about what happened — it consumes finished,
+ * scored heats (produced by [`crate::scoring::score`]) and never raw passes. The
+ * `heat` id ties the result back to the [`HeatPlan`] that produced it, so a
+ * generator that emitted several heats can tell which result is which.
+ */
+export type CompletedHeat = {
+/**
+ * Which planned heat this result is for.
+ */
+heat: HeatId,
+/**
+ * The scored result of that heat.
+ */
+result: HeatResult, };
diff --git a/bindings/ContractVersion.ts b/bindings/ContractVersion.ts
new file mode 100644
index 0000000..5adfafd
--- /dev/null
+++ b/bindings/ContractVersion.ts
@@ -0,0 +1,14 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * The protocol **contract version** — axis 1 of the three version axes
+ * (protocol.html §7): the wire shapes owned by this crate, kept distinct from the
+ * log/schema version and the projection version.
+ *
+ * A single monotonic integer. A breaking change to any wire type bumps it; additive
+ * changes (new projections, new fields an older client ignores) do not. The version
+ * is negotiated at connect time ([`Hello`] / [`ServerHello`]) and is independent of
+ * the transport (LAN or Cloud) — see [`CONTRACT_VERSION`] for the version this build
+ * speaks.
+ */
+export type ContractVersion = number;
diff --git a/bindings/Cursor.ts b/bindings/Cursor.ts
new file mode 100644
index 0000000..b193c80
--- /dev/null
+++ b/bindings/Cursor.ts
@@ -0,0 +1,17 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * The public **projection sequence number** (protocol.html §3, §9.5): a single
+ * monotonic integer per stream, increasing by one per envelope.
+ *
+ * It is *not* the raw log offset — one log append can fan out into several projection
+ * changes or none a client subscribes to. The protocol commits to this projection
+ * sequence as its public ordering; the log offset stays a private Director/Cloud
+ * detail. The snapshot hands the client a starting cursor (§2) and every envelope
+ * advances it; on reconnect the client presents its last-applied cursor to resume
+ * (§3).
+ *
+ * Transparent `u64` newtype; `#[ts(as = "bigint")]` so it renders as a TS `bigint`
+ * (a `u64` exceeds JS's safe-integer range), matching how it serialises.
+ */
+export type Cursor = bigint;
diff --git a/bindings/ErrorCode.ts b/bindings/ErrorCode.ts
new file mode 100644
index 0000000..6b50bf9
--- /dev/null
+++ b/bindings/ErrorCode.ts
@@ -0,0 +1,12 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * A machine-readable error category — the cases protocol.html §9.8 enumerates
+ * ("auth failure, unknown scope, stale-cursor, version-mismatch"), plus a generic
+ * fallback. Externally tagged like the event model so it maps to a TS string union.
+ *
+ * Adding a new variant is an additive change (§7): an older client that does not
+ * understand a new code treats it as it would [`ErrorCode::Internal`] — an error it
+ * surfaces but cannot specifically branch on.
+ */
+export type ErrorCode = "Unauthorized" | "UnknownScope" | "StaleCursor" | "VersionMismatch" | "BadRequest" | "Internal";
diff --git a/bindings/EventId.ts b/bindings/EventId.ts
new file mode 100644
index 0000000..9f13d2e
--- /dev/null
+++ b/bindings/EventId.ts
@@ -0,0 +1,10 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Identifies an **event** — the whole competition (protocol.html §4 "Event scope").
+ *
+ * A transparent string newtype like the event-model ids. The cross-event registry
+ * and account model are a Cloud concern (protocol.html callout); this is just the
+ * stable handle a scope addresses, sufficient for the wire contract.
+ */
+export type EventId = string;
diff --git a/bindings/EventOutcome.ts b/bindings/EventOutcome.ts
new file mode 100644
index 0000000..2e4241f
--- /dev/null
+++ b/bindings/EventOutcome.ts
@@ -0,0 +1,30 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { CompetitorRef } from "./CompetitorRef";
+import type { CompletedHeat } from "./CompletedHeat";
+import type { RankEntry } from "./RankEntry";
+
+/**
+ * The result of running a whole event: the qualifying ranking that seeded the bracket,
+ * the bracket's final standings, and the single winner at the top of them.
+ */
+export type EventOutcome = {
+/**
+ * The qualifying phase's final ranking (best seed first) — what seeds the bracket.
+ */
+qualifying: Array,
+/**
+ * The completed qualifying heats, in run order.
+ */
+qualifying_heats: Array,
+/**
+ * The seeded field that entered the bracket: the top `bracket_size` of `qualifying`.
+ */
+bracket_seeds: Array,
+/**
+ * The bracket's final ranking (winner first) — the event standings.
+ */
+bracket: Array,
+/**
+ * The completed bracket heats, in run order.
+ */
+bracket_heats: Array, };
diff --git a/bindings/HeatPhase.ts b/bindings/HeatPhase.ts
new file mode 100644
index 0000000..d0abac6
--- /dev/null
+++ b/bindings/HeatPhase.ts
@@ -0,0 +1,13 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * The heat-loop phase the live state reports (protocol.html §1: `Scheduled → Staged →
+ * Armed → Running → Landed → Scored`).
+ *
+ * This is the *projected* view of the heat loop — the folded current phase a client
+ * renders — not the raw [`HeatTransition`](gridfpv_events::HeatTransition) event the
+ * engine appends. The off-ramp transitions (abort/restart/discard) resolve back onto
+ * one of these phases, so the live view stays a simple linear status. A #41-era detail
+ * the placeholder pins minimally.
+ */
+export type HeatPhase = "Scheduled" | "Staged" | "Armed" | "Running" | "Landed" | "Scored";
diff --git a/bindings/HeatResult.ts b/bindings/HeatResult.ts
new file mode 100644
index 0000000..1811675
--- /dev/null
+++ b/bindings/HeatResult.ts
@@ -0,0 +1,15 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { Placement } from "./Placement";
+
+/**
+ * The scored heat: every competitor's [`Placement`], best position first.
+ *
+ * Ties share a `position` (see [`Placement::position`]). The order within a tie
+ * group is still deterministic — competitors are ordered by [`CompetitorKey`] as
+ * the final, total tie-break — but their `position` numbers are equal.
+ */
+export type HeatResult = {
+/**
+ * Placements in finishing order (ties adjacent, sharing a position).
+ */
+places: Array, };
diff --git a/bindings/Hello.ts b/bindings/Hello.ts
new file mode 100644
index 0000000..91e1013
--- /dev/null
+++ b/bindings/Hello.ts
@@ -0,0 +1,18 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { ContractVersion } from "./ContractVersion";
+
+/**
+ * The client's **connect message** (protocol.html §7): announced before anything
+ * else, it carries the [`ContractVersion`] the client was built against so the
+ * server can decide whether it can serve it.
+ *
+ * "The contract version is negotiated, not assumed" (§7): the client states its
+ * version, the server replies with the band it serves ([`ServerHello`]). The version
+ * is the only thing negotiated here; auth (a bearer token, §9.4) is a separate #44
+ * concern layered in front, not part of this message yet.
+ */
+export type Hello = {
+/**
+ * The contract version the client was built against.
+ */
+contract_version: ContractVersion, };
diff --git a/bindings/Lap.ts b/bindings/Lap.ts
new file mode 100644
index 0000000..bb48203
--- /dev/null
+++ b/bindings/Lap.ts
@@ -0,0 +1,15 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * A single completed lap: the interval between two consecutive lap-gate passes.
+ */
+export type Lap = {
+/**
+ * 1-based lap number within the competitor's run.
+ */
+number: number,
+/**
+ * Lap duration in microseconds on the source clock
+ * (`pass[n + 1].at - pass[n].at`). Always `>= 0` for in-order passes.
+ */
+duration_micros: bigint, };
diff --git a/bindings/LapList.ts b/bindings/LapList.ts
new file mode 100644
index 0000000..bb16e08
--- /dev/null
+++ b/bindings/LapList.ts
@@ -0,0 +1,14 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { CompetitorLaps } from "./CompetitorLaps";
+
+/**
+ * The lap-list read model: per-competitor lap lists derived from the log.
+ *
+ * Competitors are ordered deterministically by [`CompetitorKey`] so the
+ * projection is stable across runs regardless of event arrival order.
+ */
+export type LapList = {
+/**
+ * Per-competitor laps, ordered by competitor key.
+ */
+competitors: Array, };
diff --git a/bindings/LiveRaceState.ts b/bindings/LiveRaceState.ts
new file mode 100644
index 0000000..57d8b82
--- /dev/null
+++ b/bindings/LiveRaceState.ts
@@ -0,0 +1,24 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { HeatId } from "./HeatId";
+import type { HeatPhase } from "./HeatPhase";
+
+/**
+ * The current **live race-state** projection (protocol.html §1) — the latency-sensitive
+ * core every overlay and spectator watches: the current heat and its loop state, the
+ * active pilots' live lap/split progress, the running order, and the on-deck heat.
+ *
+ * **#41 placeholder.** This is intentionally minimal-but-real: it carries the current
+ * heat id and a coarse [`HeatPhase`] so the contract, the snapshot body, and the
+ * change envelope are all wired end to end *now*. #41 fleshes it out with per-pilot
+ * live progress, the running order, and the on-deck heat. Keeping the type real (not a
+ * unit stub) means #41 extends fields additively (§7) without reshaping the envelope.
+ */
+export type LiveRaceState = {
+/**
+ * The heat currently on the timer, if any (`None` between heats).
+ */
+current_heat?: HeatId,
+/**
+ * The current heat's loop phase (protocol.html §1, race-engine.html §2).
+ */
+phase: HeatPhase, };
diff --git a/bindings/Metric.ts b/bindings/Metric.ts
new file mode 100644
index 0000000..93c2e69
--- /dev/null
+++ b/bindings/Metric.ts
@@ -0,0 +1,8 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { SourceTime } from "./SourceTime";
+
+/**
+ * The condition-specific value a [`Placement`] was ranked on, kept for display and
+ * for tests to assert against exact numbers.
+ */
+export type Metric = { "LastLapAt": SourceTime | null } | { "ReachedAt": SourceTime | null } | { "BestLapMicros": bigint | null } | { "BestConsecutiveMicros": bigint | null };
diff --git a/bindings/PilotId.ts b/bindings/PilotId.ts
new file mode 100644
index 0000000..63cfca9
--- /dev/null
+++ b/bindings/PilotId.ts
@@ -0,0 +1,12 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Identifies a **pilot** across an event (protocol.html §4 "Pilot scope") — a racer
+ * following their own laps, results, and next heat.
+ *
+ * This is the event-scoped pilot handle a scope addresses, distinct from the
+ * per-source [`CompetitorRef`](gridfpv_events::CompetitorRef) the timer reports: a
+ * pilot is bound to source competitors by a registration action (Architecture §9),
+ * which is out of scope here.
+ */
+export type PilotId = string;
diff --git a/bindings/Placement.ts b/bindings/Placement.ts
new file mode 100644
index 0000000..32cf523
--- /dev/null
+++ b/bindings/Placement.ts
@@ -0,0 +1,31 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { CompetitorKey } from "./CompetitorKey";
+import type { Metric } from "./Metric";
+
+/**
+ * One competitor's place in a scored heat.
+ *
+ * `position` is **1-based and dense at the top but tie-aware**: tied competitors
+ * share the same `position`, and the next distinct competitor's `position` skips
+ * past them (standard "1, 2, 2, 4" competition ranking). `laps` is the number of
+ * laps that counted under the win condition (for [`WinCondition::Timed`] that is
+ * the number inside the window, not the raw laps flown). `metric` carries the
+ * condition-specific deciding value for display / debugging.
+ */
+export type Placement = {
+/**
+ * Which source-local competitor this placement is for.
+ */
+competitor: CompetitorKey,
+/**
+ * 1-based finishing position; tied competitors share a position.
+ */
+position: number,
+/**
+ * Laps that counted under the win condition.
+ */
+laps: number,
+/**
+ * The condition-specific deciding metric for this competitor.
+ */
+metric: Metric, };
diff --git a/bindings/ProjectionBody.ts b/bindings/ProjectionBody.ts
new file mode 100644
index 0000000..04b980e
--- /dev/null
+++ b/bindings/ProjectionBody.ts
@@ -0,0 +1,18 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { EventOutcome } from "./EventOutcome";
+import type { HeatResult } from "./HeatResult";
+import type { LapList } from "./LapList";
+import type { LiveRaceState } from "./LiveRaceState";
+import type { RankEntry } from "./RankEntry";
+
+/**
+ * A served projection **with its value** (protocol.html §1) — the closed set of
+ * projection shapes the contract carries, each reusing the existing projection/engine
+ * output type. Externally tagged, so it maps to a TS discriminated union keyed by
+ * projection kind.
+ *
+ * This is the body of a [`Snapshot`] and of a fresh-value
+ * [`ChangeEnvelope`](crate::stream::ChangeEnvelope). Adding a new served projection is
+ * an additive variant (§7); an older client ignores a kind it does not understand.
+ */
+export type ProjectionBody = { "LiveRaceState": LiveRaceState } | { "LapList": LapList } | { "HeatResult": HeatResult } | { "Ranking": Array } | { "EventOutcome": EventOutcome };
diff --git a/bindings/ProjectionKind.ts b/bindings/ProjectionKind.ts
new file mode 100644
index 0000000..5ad99c3
--- /dev/null
+++ b/bindings/ProjectionKind.ts
@@ -0,0 +1,12 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Which projection a snapshot body or change envelope is *about* — the bare
+ * discriminant with no payload (protocol.html §3).
+ *
+ * A fresh-value envelope carries the whole [`ProjectionBody`] (kind *and* value); a
+ * delta envelope carries this `ProjectionKind` plus an opaque delta, naming the
+ * projection it advances without re-sending it. Keeping the kind separate from the
+ * body lets a delta name its target cheaply.
+ */
+export type ProjectionKind = "LiveRaceState" | "LapList" | "HeatResult" | "Ranking" | "EventOutcome";
diff --git a/bindings/ProtocolError.ts b/bindings/ProtocolError.ts
new file mode 100644
index 0000000..242d526
--- /dev/null
+++ b/bindings/ProtocolError.ts
@@ -0,0 +1,19 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { ErrorCode } from "./ErrorCode";
+
+/**
+ * The one error shape every protocol surface returns (protocol.html §9.8).
+ *
+ * Carried in an HTTP error body, a WS error frame, and a failed
+ * [`CommandAck`](crate::control::CommandAck) alike. `code` is the branchable
+ * category; `message` is the human-readable detail for logs and the RD console.
+ */
+export type ProtocolError = {
+/**
+ * The machine-readable error category.
+ */
+code: ErrorCode,
+/**
+ * A human-readable description of what went wrong.
+ */
+message: string, };
diff --git a/bindings/RankEntry.ts b/bindings/RankEntry.ts
new file mode 100644
index 0000000..c0e28a0
--- /dev/null
+++ b/bindings/RankEntry.ts
@@ -0,0 +1,21 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { CompetitorRef } from "./CompetitorRef";
+
+/**
+ * One competitor's place in a generator's overall ranking.
+ *
+ * `position` is **1-based and tie-aware** with the same "competition ranking"
+ * convention as [`crate::scoring::Placement`]: tied competitors share a `position`
+ * and the next distinct entry skips past them (1, 2, 2, 4). Entries are returned in
+ * ranking order (best first), with a total, deterministic tie-break so the order is
+ * stable across runs.
+ */
+export type RankEntry = {
+/**
+ * The competitor this entry ranks.
+ */
+competitor: CompetitorRef,
+/**
+ * 1-based overall position; tied competitors share a position.
+ */
+position: number, };
diff --git a/bindings/Scope.ts b/bindings/Scope.ts
new file mode 100644
index 0000000..ab08c8b
--- /dev/null
+++ b/bindings/Scope.ts
@@ -0,0 +1,41 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { ClassId } from "./ClassId";
+import type { EventId } from "./EventId";
+import type { HeatId } from "./HeatId";
+import type { PilotId } from "./PilotId";
+
+/**
+ * What a subscription addresses (protocol.html §4): one of the four resources a
+ * client can scope to. Externally tagged like the event model, so it maps to a TS
+ * discriminated union.
+ *
+ * Scopes compose and a client may hold several at once over one connection (§4); a
+ * multi-scope subscribe is a list of these. The richer filter grammar (§9.6) is
+ * deferred — this fixes the addressable *resources*, which is what later issues build
+ * the grammar over.
+ */
+export type Scope = { "Event": {
+/**
+ * The event addressed.
+ */
+event: EventId, } } | { "Class": {
+/**
+ * The event the class belongs to.
+ */
+event: EventId,
+/**
+ * The class addressed.
+ */
+class: ClassId, } } | { "Heat": {
+/**
+ * The heat addressed (the id it carries in the log).
+ */
+heat: HeatId, } } | { "Pilot": {
+/**
+ * The event the pilot is racing in.
+ */
+event: EventId,
+/**
+ * The pilot addressed.
+ */
+pilot: PilotId, } };
diff --git a/bindings/ServerHello.ts b/bindings/ServerHello.ts
new file mode 100644
index 0000000..8a94cc3
--- /dev/null
+++ b/bindings/ServerHello.ts
@@ -0,0 +1,26 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { ContractVersion } from "./ContractVersion";
+
+/**
+ * The server's reply to a [`Hello`] (protocol.html §7).
+ *
+ * The server "states the version(s) it serves" as an inclusive band
+ * `[min_contract_version, max_contract_version]`. A client whose
+ * [`Hello::contract_version`] falls inside the band is served; one that falls
+ * outside gets `compatible = false` — the explicit "too old / too new, please
+ * refresh" signal — rather than malformed data.
+ */
+export type ServerHello = {
+/**
+ * The oldest contract version this server still serves (inclusive).
+ */
+min_contract_version: ContractVersion,
+/**
+ * The newest contract version this server serves (inclusive).
+ */
+max_contract_version: ContractVersion,
+/**
+ * Whether the client's announced version falls within the served band. `false`
+ * is the "please refresh" signal (the client is too old or too new to be served).
+ */
+compatible: boolean, };
diff --git a/bindings/Snapshot.ts b/bindings/Snapshot.ts
new file mode 100644
index 0000000..d9d81c0
--- /dev/null
+++ b/bindings/Snapshot.ts
@@ -0,0 +1,23 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { Cursor } from "./Cursor";
+import type { ProjectionBody } from "./ProjectionBody";
+
+/**
+ * A snapshot of a scoped projection (protocol.html §2): the current materialized
+ * [`ProjectionBody`] plus the [`Cursor`] the change stream resumes from.
+ *
+ * "Snapshot first, then subscribe" (§2): the client renders the `body` immediately and
+ * opens the stream `from` this `cursor`, so the first envelope it applies is exactly
+ * the one after the snapshot — nothing missed, nothing double-applied. Re-snapshotting
+ * is always correct because projections are recomputable; the stream is an
+ * optimization, never the source of truth (§3).
+ */
+export type Snapshot = {
+/**
+ * The stream cursor this snapshot was taken at — where the subscription resumes.
+ */
+cursor: Cursor,
+/**
+ * The materialized projection at that cursor.
+ */
+body: ProjectionBody, };
diff --git a/bindings/SubscribeRequest.ts b/bindings/SubscribeRequest.ts
new file mode 100644
index 0000000..33512c6
--- /dev/null
+++ b/bindings/SubscribeRequest.ts
@@ -0,0 +1,24 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { Cursor } from "./Cursor";
+import type { Scope } from "./Scope";
+
+/**
+ * A subscription request (protocol.html §3, §4): a [`Scope`] plus where to resume
+ * the stream from.
+ *
+ * `from` is the last-applied [`Cursor`](crate::stream::Cursor) the client presents on
+ * (re)connect — `Some(cursor)` to resume after a drop, `None` for a fresh
+ * subscription that begins from the snapshot's cursor (protocol.html §3 "resume by
+ * cursor, fall back to re-snapshot"). If the gap is too old to replay the server
+ * answers with a [`StaleCursor`](crate::error::ErrorCode::StaleCursor) error and the
+ * client re-snapshots.
+ */
+export type SubscribeRequest = {
+/**
+ * The resource this subscription is scoped to.
+ */
+scope: Scope,
+/**
+ * The cursor to resume after, or `None` to start fresh from the snapshot.
+ */
+from?: Cursor, };
diff --git a/bindings/WinCondition.ts b/bindings/WinCondition.ts
new file mode 100644
index 0000000..872c2ab
--- /dev/null
+++ b/bindings/WinCondition.ts
@@ -0,0 +1,19 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * How a heat is won — the configured per-heat / per-format scoring rule
+ * (race-engine.html §4, §7.1).
+ */
+export type WinCondition = { "Timed": {
+/**
+ * Window length in microseconds, measured from the race start.
+ */
+window_micros: bigint, } } | { "FirstToLaps": {
+/**
+ * The target lap count.
+ */
+n: number, } } | "BestLap" | { "BestConsecutive": {
+/**
+ * How many consecutive laps the window spans.
+ */
+n: number, } };
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index 1b53548..0c7ab8a 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -19,6 +19,10 @@ gridfpv-events.workspace = true
# Scoring (#30) and the other engine features build on the projection; add it now so
# the v0.3 wave that lands them doesn't have to touch this manifest again.
gridfpv-projection.workspace = true
+serde.workspace = true
+# Rust→TypeScript type export (#4, #40): heat results, rankings, and the full-event
+# outcome are served snapshot bodies, so their public output types generate TS bindings.
+ts-rs = "12"
gridfpv-adapters = { workspace = true, optional = true }
gridfpv-testkit = { workspace = true, optional = true }
diff --git a/crates/engine/src/event.rs b/crates/engine/src/event.rs
index cf72204..0139f64 100644
--- a/crates/engine/src/event.rs
+++ b/crates/engine/src/event.rs
@@ -44,6 +44,8 @@
use std::collections::BTreeMap;
use gridfpv_events::{Event, Pass, SourceTime};
+use serde::{Deserialize, Serialize};
+use ts_rs::TS;
use crate::format::{CompletedHeat, Generator, GeneratorStep, HeatPlan, RankEntry, advance_top_n};
use crate::scoring::{HeatResult, WinCondition, score};
@@ -92,7 +94,8 @@ pub fn run_format(
/// The result of running a whole event: the qualifying ranking that seeded the bracket,
/// the bracket's final standings, and the single winner at the top of them.
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
pub struct EventOutcome {
/// The qualifying phase's final ranking (best seed first) — what seeds the bracket.
pub qualifying: Vec,
diff --git a/crates/engine/src/format.rs b/crates/engine/src/format.rs
index 5c4995d..e76436c 100644
--- a/crates/engine/src/format.rs
+++ b/crates/engine/src/format.rs
@@ -48,6 +48,8 @@
use std::collections::BTreeMap;
use gridfpv_events::{CompetitorRef, HeatId};
+use serde::{Deserialize, Serialize};
+use ts_rs::TS;
use crate::scoring::HeatResult;
@@ -80,7 +82,8 @@ impl HeatPlan {
/// scored heats (produced by [`crate::scoring::score`]) and never raw passes. The
/// `heat` id ties the result back to the [`HeatPlan`] that produced it, so a
/// generator that emitted several heats can tell which result is which.
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
pub struct CompletedHeat {
/// Which planned heat this result is for.
pub heat: HeatId,
@@ -119,7 +122,8 @@ pub enum GeneratorStep {
/// and the next distinct entry skips past them (1, 2, 2, 4). Entries are returned in
/// ranking order (best first), with a total, deterministic tie-break so the order is
/// stable across runs.
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
pub struct RankEntry {
/// The competitor this entry ranks.
pub competitor: CompetitorRef,
diff --git a/crates/engine/src/scoring.rs b/crates/engine/src/scoring.rs
index 503446d..cd5cf64 100644
--- a/crates/engine/src/scoring.rs
+++ b/crates/engine/src/scoring.rs
@@ -41,10 +41,13 @@
use gridfpv_events::{Event, Pass, SourceTime};
use gridfpv_projection::CompetitorKey;
+use serde::{Deserialize, Serialize};
+use ts_rs::TS;
/// How a heat is won — the configured per-heat / per-format scoring rule
/// (race-engine.html §4, §7.1).
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
pub enum WinCondition {
/// **Most laps in a time window.** A shared race clock runs from `race_start`
/// for `window_micros`. A lap counts only if its completing pass falls
@@ -86,7 +89,8 @@ pub enum WinCondition {
/// laps that counted under the win condition (for [`WinCondition::Timed`] that is
/// the number inside the window, not the raw laps flown). `metric` carries the
/// condition-specific deciding value for display / debugging.
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
pub struct Placement {
/// Which source-local competitor this placement is for.
pub competitor: CompetitorKey,
@@ -100,7 +104,8 @@ pub struct Placement {
/// The condition-specific value a [`Placement`] was ranked on, kept for display and
/// for tests to assert against exact numbers.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
pub enum Metric {
/// [`WinCondition::Timed`]: completion time (µs, source clock) of the last
/// counted lap, or `None` if no lap counted.
@@ -120,7 +125,8 @@ pub enum Metric {
/// Ties share a `position` (see [`Placement::position`]). The order within a tie
/// group is still deterministic — competitors are ordered by [`CompetitorKey`] as
/// the final, total tie-break — but their `position` numbers are equal.
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
pub struct HeatResult {
/// Placements in finishing order (ties adjacent, sharing a position).
pub places: Vec,
diff --git a/crates/projection/Cargo.toml b/crates/projection/Cargo.toml
index 23b6dcb..d661537 100644
--- a/crates/projection/Cargo.toml
+++ b/crates/projection/Cargo.toml
@@ -9,6 +9,9 @@ rust-version.workspace = true
[dependencies]
gridfpv-events.workspace = true
serde.workspace = true
+# Rust→TypeScript type export (#4, #40): the lap projection is a served snapshot
+# body, so its public output types generate TS bindings alongside the event model.
+ts-rs = "12"
[dev-dependencies]
serde_json.workspace = true
diff --git a/crates/projection/src/lib.rs b/crates/projection/src/lib.rs
index 2593f59..6812904 100644
--- a/crates/projection/src/lib.rs
+++ b/crates/projection/src/lib.rs
@@ -30,6 +30,7 @@ use std::collections::BTreeMap;
use gridfpv_events::{AdapterId, CompetitorRef, Event, Pass, SourceTime};
use serde::{Deserialize, Serialize};
+use ts_rs::TS;
/// Identifies a competitor *within a single timing source*.
///
@@ -38,7 +39,8 @@ use serde::{Deserialize, Serialize};
/// timer), so laps are grouped on the `(AdapterId, CompetitorRef)` pair. Binding
/// these per-source competitors to a single GridFPV pilot is a later registration
/// concern (Architecture §9) and deliberately out of scope here.
-#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
pub struct CompetitorKey {
/// The timing source the competitor belongs to.
pub adapter: AdapterId,
@@ -57,7 +59,8 @@ impl CompetitorKey {
}
/// A single completed lap: the interval between two consecutive lap-gate passes.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
pub struct Lap {
/// 1-based lap number within the competitor's run.
pub number: u32,
@@ -67,7 +70,8 @@ pub struct Lap {
}
/// Every lap a single competitor completed, in order.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
pub struct CompetitorLaps {
/// Which source-local competitor these laps belong to.
pub competitor: CompetitorKey,
@@ -96,7 +100,8 @@ impl CompetitorLaps {
///
/// Competitors are ordered deterministically by [`CompetitorKey`] so the
/// projection is stable across runs regardless of event arrival order.
-#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
pub struct LapList {
/// Per-competitor laps, ordered by competitor key.
pub competitors: Vec,
diff --git a/crates/server/src/control.rs b/crates/server/src/control.rs
new file mode 100644
index 0000000..b90fb83
--- /dev/null
+++ b/crates/server/src/control.rs
@@ -0,0 +1,316 @@
+//! The RD control path (protocol.html §5): the privileged commands the Race Director
+//! issues, and the acknowledgements they get back.
+//!
+//! Control is **authenticated and Director-local** (§5): race control, marshaling, and
+//! scheduling require the RD's authenticated role and run only against the Director —
+//! the Cloud exposes no control path at all. This module is the *command vocabulary*;
+//! the auth that gates it (#44) and the axum endpoints that carry it (#45) are later
+//! issues.
+//!
+//! Every [`Command`] maps to an action the engine/marshaling layer already models — a
+//! heat-loop transition ([`HeatTransition`](gridfpv_events::HeatTransition)), a
+//! schedule ([`Event::HeatScheduled`](gridfpv_events::Event::HeatScheduled)), a
+//! registration binding, or one of the five marshaling adjudications. A command is a
+//! *request* to append the corresponding event(s); the engine validates legality
+//! against current state and answers with a [`CommandAck`].
+//!
+//! > **Note (scope/addressing deferred):** commands address heats and competitors by
+//! > the ids the event model already uses ([`HeatId`](gridfpv_events::HeatId),
+//! > [`CompetitorRef`](gridfpv_events::CompetitorRef),
+//! > [`LogRef`](gridfpv_events::LogRef)). The richer scope grammar and any
+//! > event/class addressing on commands (protocol.html §9.6) are refined alongside the
+//! > control endpoints (#45) and the doc-reconciliation pass.
+
+use gridfpv_events::{AdapterId, CompetitorRef, HeatId, LogRef, Penalty, SourceTime};
+use serde::{Deserialize, Serialize};
+use ts_rs::TS;
+
+use crate::error::ProtocolError;
+
+/// A privileged RD control command (protocol.html §5). Externally tagged like the
+/// event model, so it maps to a TS discriminated union.
+///
+/// The variants fall into four groups:
+///
+/// - **Heat-loop transitions** — [`Stage`](Command::Stage), [`Arm`](Command::Arm),
+/// [`Start`](Command::Start), [`Finish`](Command::Finish), [`Score`](Command::Score),
+/// [`Advance`](Command::Advance), and the off-ramps [`Abort`](Command::Abort),
+/// [`Restart`](Command::Restart), [`Discard`](Command::Discard). Each requests the
+/// matching [`HeatTransition`](gridfpv_events::HeatTransition); the engine validates
+/// it against the heat's current state (race-engine.html §2).
+/// - **Scheduling** — [`ScheduleHeat`](Command::ScheduleHeat) creates a heat with its
+/// lineup ([`Event::HeatScheduled`](gridfpv_events::Event::HeatScheduled)).
+/// - **Registration** — [`Register`](Command::Register) binds a source-local
+/// competitor to a pilot (the binding the adapter never does itself; Architecture §9).
+/// - **Marshaling adjudications** — the five corrections
+/// ([`VoidDetection`](Command::VoidDetection), [`InsertLap`](Command::InsertLap),
+/// [`AdjustLap`](Command::AdjustLap), [`VoidHeat`](Command::VoidHeat),
+/// [`ApplyPenalty`](Command::ApplyPenalty)), each requesting the corresponding
+/// marshaling event the projection folds in (never a mutation; architecture.html §3).
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub enum Command {
+ // --- Heat-loop transitions (race-engine.html §2) ---
+ /// Begin staging the heat — countdown starts, frequencies are assigned.
+ Stage {
+ /// The heat to transition.
+ heat: HeatId,
+ },
+ /// Arm the heat — open the gate to detections.
+ Arm {
+ /// The heat to transition.
+ heat: HeatId,
+ },
+ /// Start the heat running — passes are consumed from here.
+ Start {
+ /// The heat to transition.
+ heat: HeatId,
+ },
+ /// Finish the heat — close the race (time elapsed or all landed).
+ Finish {
+ /// The heat to transition.
+ heat: HeatId,
+ },
+ /// Score the heat — finalize the result.
+ Score {
+ /// The heat to transition.
+ heat: HeatId,
+ },
+ /// Advance the scored heat — hand its result to the format generator.
+ Advance {
+ /// The heat to transition.
+ heat: HeatId,
+ },
+ /// Abort the heat — abandon it before scoring (an off-ramp).
+ Abort {
+ /// The heat to transition.
+ heat: HeatId,
+ },
+ /// Restart a running heat — back to staging for a re-run.
+ Restart {
+ /// The heat to transition.
+ heat: HeatId,
+ },
+ /// Discard a scored heat — drop its result for a re-run.
+ Discard {
+ /// The heat to transition.
+ heat: HeatId,
+ },
+
+ // --- Scheduling ---
+ /// Create a heat with its lineup (`Event::HeatScheduled`). Seat/frequency
+ /// assignment is the scheduler's concern; this commits who is in the heat.
+ ScheduleHeat {
+ /// The id the new heat will carry.
+ heat: HeatId,
+ /// The competitors in the heat, in lineup order.
+ lineup: Vec,
+ },
+
+ // --- Registration ---
+ /// Bind a source-local competitor to a GridFPV pilot (Architecture §9) — the
+ /// registration the adapter never performs. The pilot handle is the event-scoped
+ /// [`PilotId`](crate::scope::PilotId).
+ Register {
+ /// The timing source the competitor belongs to.
+ adapter: AdapterId,
+ /// The source-local competitor handle being bound.
+ competitor: CompetitorRef,
+ /// The event-scoped pilot the competitor is bound to.
+ pilot: crate::scope::PilotId,
+ },
+
+ // --- Marshaling adjudications (architecture.html §3, the five corrections) ---
+ /// Void a previously-detected pass, referenced by its log offset
+ /// (`Event::DetectionVoided`).
+ VoidDetection {
+ /// The log offset of the pass (or ruling) to void.
+ target: LogRef,
+ },
+ /// Insert a lap-gate pass the timer missed (`Event::LapInserted`).
+ InsertLap {
+ /// The timing source to attribute the inserted pass to.
+ adapter: AdapterId,
+ /// The competitor the inserted lap belongs to.
+ competitor: CompetitorRef,
+ /// When the inserted crossing happened, on the source clock.
+ at: SourceTime,
+ },
+ /// Re-time a previously-detected pass (`Event::LapAdjusted`).
+ AdjustLap {
+ /// The log offset of the pass to re-time.
+ target: LogRef,
+ /// The corrected crossing time, on the source clock.
+ at: SourceTime,
+ },
+ /// Void an entire heat (`Event::HeatVoided`).
+ VoidHeat {
+ /// The heat to void.
+ heat: HeatId,
+ },
+ /// Apply a penalty to a competitor in a heat (`Event::PenaltyApplied`).
+ ApplyPenalty {
+ /// The heat the penalty applies in.
+ heat: HeatId,
+ /// The competitor penalized.
+ competitor: CompetitorRef,
+ /// The penalty applied.
+ penalty: Penalty,
+ },
+}
+
+/// The acknowledgement of a [`Command`] (protocol.html §5): commands up,
+/// acknowledgements down.
+///
+/// `ok` is the success flag; on failure `error` carries the single shared
+/// [`ProtocolError`](crate::error::ProtocolError) (§9.8) — an illegal transition for
+/// the heat's state, an unauthorized caller, an unknown heat. On success `error` is
+/// `None`. (The resulting projection state flows back separately as
+/// [`ChangeEnvelope`](crate::stream::ChangeEnvelope)s on the read stream, not in the
+/// ack.)
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub struct CommandAck {
+ /// Whether the command was accepted and applied.
+ pub ok: bool,
+ /// The failure detail when `ok` is `false`; `None` on success.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[ts(optional)]
+ pub error: Option,
+}
+
+impl CommandAck {
+ /// A successful acknowledgement.
+ pub fn ok() -> Self {
+ Self {
+ ok: true,
+ error: None,
+ }
+ }
+
+ /// A failed acknowledgement carrying the error detail.
+ pub fn failed(error: ProtocolError) -> Self {
+ Self {
+ ok: false,
+ error: Some(error),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::error::ErrorCode;
+ use crate::scope::PilotId;
+
+ #[test]
+ fn heat_transition_commands_round_trip() {
+ let commands = vec![
+ Command::Stage {
+ heat: HeatId("q-1".into()),
+ },
+ Command::Arm {
+ heat: HeatId("q-1".into()),
+ },
+ Command::Start {
+ heat: HeatId("q-1".into()),
+ },
+ Command::Finish {
+ heat: HeatId("q-1".into()),
+ },
+ Command::Score {
+ heat: HeatId("q-1".into()),
+ },
+ Command::Advance {
+ heat: HeatId("q-1".into()),
+ },
+ Command::Abort {
+ heat: HeatId("q-1".into()),
+ },
+ Command::Restart {
+ heat: HeatId("q-1".into()),
+ },
+ Command::Discard {
+ heat: HeatId("q-1".into()),
+ },
+ ];
+ for command in commands {
+ let json = serde_json::to_string(&command).unwrap();
+ let back: Command = serde_json::from_str(&json).unwrap();
+ assert_eq!(command, back);
+ }
+ }
+
+ #[test]
+ fn scheduling_and_registration_round_trip() {
+ let commands = vec![
+ Command::ScheduleHeat {
+ heat: HeatId("q-1".into()),
+ lineup: vec![
+ CompetitorRef("node-0".into()),
+ CompetitorRef("node-1".into()),
+ ],
+ },
+ Command::Register {
+ adapter: AdapterId("rh".into()),
+ competitor: CompetitorRef("node-2".into()),
+ pilot: PilotId("acroace".into()),
+ },
+ ];
+ for command in commands {
+ let json = serde_json::to_string(&command).unwrap();
+ let back: Command = serde_json::from_str(&json).unwrap();
+ assert_eq!(command, back);
+ }
+ }
+
+ #[test]
+ fn all_five_marshaling_adjudications_round_trip() {
+ let commands = vec![
+ Command::VoidDetection { target: LogRef(42) },
+ Command::InsertLap {
+ adapter: AdapterId("vd".into()),
+ competitor: CompetitorRef("A".into()),
+ at: SourceTime::from_micros(5_000_000),
+ },
+ Command::AdjustLap {
+ target: LogRef(43),
+ at: SourceTime::from_micros(5_100_000),
+ },
+ Command::VoidHeat {
+ heat: HeatId("q-1".into()),
+ },
+ Command::ApplyPenalty {
+ heat: HeatId("main-a".into()),
+ competitor: CompetitorRef("A".into()),
+ penalty: Penalty::TimeAdded { micros: 2_000_000 },
+ },
+ ];
+ for command in commands {
+ let json = serde_json::to_string(&command).unwrap();
+ let back: Command = serde_json::from_str(&json).unwrap();
+ assert_eq!(command, back);
+ }
+ }
+
+ #[test]
+ fn command_ack_ok_omits_error() {
+ let ack = CommandAck::ok();
+ let json = serde_json::to_string(&ack).unwrap();
+ assert!(!json.contains("error"), "ok ack omits error: {json}");
+ let back: CommandAck = serde_json::from_str(&json).unwrap();
+ assert_eq!(ack, back);
+ }
+
+ #[test]
+ fn command_ack_failed_carries_the_error() {
+ let ack = CommandAck::failed(ProtocolError::new(
+ ErrorCode::BadRequest,
+ "cannot Arm a heat that is not Staged",
+ ));
+ let json = serde_json::to_string(&ack).unwrap();
+ let back: CommandAck = serde_json::from_str(&json).unwrap();
+ assert_eq!(ack, back);
+ assert!(!ack.ok);
+ }
+}
diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs
new file mode 100644
index 0000000..aa3d8f8
--- /dev/null
+++ b/crates/server/src/error.rs
@@ -0,0 +1,102 @@
+//! The single shared error shape (protocol.html §9.8, "Error model").
+//!
+//! The contract uses **one** error type across every path — HTTP snapshot reads, the
+//! WS change stream, and control acknowledgements — defined once in Rust like the
+//! rest of the wire contract. A client therefore handles auth failure, an unknown
+//! scope, a stale cursor, or a version mismatch through a single uniform shape rather
+//! than a different envelope per surface.
+//!
+//! The exact field set is "pinned at implementation" (§9.8); this is the minimal
+//! shape every surface needs — a machine-readable [`ErrorCode`] plus a human-readable
+//! `message` — with room to grow additively (a new code, an optional detail field)
+//! without breaking older clients.
+
+use serde::{Deserialize, Serialize};
+use ts_rs::TS;
+
+/// A machine-readable error category — the cases protocol.html §9.8 enumerates
+/// ("auth failure, unknown scope, stale-cursor, version-mismatch"), plus a generic
+/// fallback. Externally tagged like the event model so it maps to a TS string union.
+///
+/// Adding a new variant is an additive change (§7): an older client that does not
+/// understand a new code treats it as it would [`ErrorCode::Internal`] — an error it
+/// surfaces but cannot specifically branch on.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub enum ErrorCode {
+ /// The caller is not authenticated, or not authorized for this scope / action
+ /// (protocol.html §5). Covers a missing/invalid read token and an unprivileged
+ /// client attempting the control path.
+ Unauthorized,
+ /// The requested [`Scope`](crate::scope::Scope) does not exist or addresses
+ /// nothing the server can serve (an unknown heat id, a pilot not in the event).
+ UnknownScope,
+ /// The client presented a resume [`Cursor`](crate::stream::Cursor) older than the
+ /// server's retained window (protocol.html §3): the gap cannot be replayed and the
+ /// client must **re-snapshot**. The distinguished "go re-snapshot" signal.
+ StaleCursor,
+ /// The client's [`ContractVersion`](crate::ContractVersion) falls outside the band
+ /// the server serves (protocol.html §7) — the "too old / too new, please refresh"
+ /// signal as an error on a path that cannot carry a [`ServerHello`](crate::ServerHello).
+ VersionMismatch,
+ /// The request was malformed — an unparseable body, an illegal command for the
+ /// current state, a bad scope expression.
+ BadRequest,
+ /// An unexpected server-side failure. The catch-all an older client also maps an
+ /// unknown future code onto.
+ Internal,
+}
+
+/// The one error shape every protocol surface returns (protocol.html §9.8).
+///
+/// Carried in an HTTP error body, a WS error frame, and a failed
+/// [`CommandAck`](crate::control::CommandAck) alike. `code` is the branchable
+/// category; `message` is the human-readable detail for logs and the RD console.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub struct ProtocolError {
+ /// The machine-readable error category.
+ pub code: ErrorCode,
+ /// A human-readable description of what went wrong.
+ pub message: String,
+}
+
+impl ProtocolError {
+ /// Build an error from a code and a message.
+ pub fn new(code: ErrorCode, message: impl Into) -> Self {
+ Self {
+ code,
+ message: message.into(),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn protocol_error_round_trips() {
+ let err = ProtocolError::new(ErrorCode::StaleCursor, "cursor 12 is below the window");
+ let json = serde_json::to_string(&err).unwrap();
+ let back: ProtocolError = serde_json::from_str(&json).unwrap();
+ assert_eq!(err, back);
+ }
+
+ #[test]
+ fn every_error_code_round_trips() {
+ use ErrorCode::*;
+ for code in [
+ Unauthorized,
+ UnknownScope,
+ StaleCursor,
+ VersionMismatch,
+ BadRequest,
+ Internal,
+ ] {
+ let json = serde_json::to_string(&code).unwrap();
+ let back: ErrorCode = serde_json::from_str(&json).unwrap();
+ assert_eq!(code, back);
+ }
+ }
+}
diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs
index 394e27a..59d9ed7 100644
--- a/crates/server/src/lib.rs
+++ b/crates/server/src/lib.rs
@@ -2,4 +2,158 @@
//! stream) plus the RD control path, served over axum on the Director (and reused
//! by the Cloud). The wire types are defined here once and generated to TypeScript
//! (ts-rs), so clients never hand-write a wire type. See docs/protocol.html.
+//!
+//! # What this crate is (and is not), as of #40
+//!
+//! This is the **wire-type surface only** — the Rust types that *are* the protocol
+//! (protocol.html §6: "the contract defined once, in Rust"). There is **no transport
+//! here yet**: the axum HTTP/WS server (snapshot endpoint #42, change stream #43,
+//! auth #44, control #45) is built on top of these types in later issues. Defining
+//! the types first means every one of those issues — and the frontend protocol
+//! client (#49) — builds against a single generated contract that already exists.
+//!
+//! Every public wire type derives [`serde`] (its JSON encoding *is* the wire format)
+//! and [`ts_rs::TS`] with `#[ts(export, export_to = "bindings/")]`, exactly mirroring
+//! the event model in `gridfpv-events`. `cargo xtask gen` runs the ts-rs export tests
+//! across every crate that exports (events, projection, engine, server) with
+//! `TS_RS_EXPORT_DIR` pinned to the repo root, so all `.ts` files land in
+//! `/bindings/`; `cargo xtask ci` drift-checks them.
+//!
+//! # The shape of the contract (protocol.html §2–§7)
+//!
+//! 1. A client connects and announces the [`ContractVersion`] it was built against in
+//! a [`Hello`] (§7); the server replies with the band it serves ([`ServerHello`]).
+//! 2. It fetches a [`Snapshot`](snapshot::Snapshot) of a [`Scope`](scope::Scope) — the
+//! current materialized projection plus the [`Cursor`](stream::Cursor) the stream
+//! resumes from (§2).
+//! 3. It subscribes ([`SubscribeRequest`](scope::SubscribeRequest)) and receives an
+//! ordered run of [`ChangeEnvelope`](stream::ChangeEnvelope)s, each a per-projection
+//! delta-or-fresh-value carrying the monotonic [`Cursor`](stream::Cursor) (§3).
+//! 4. The RD additionally drives the control path: [`Command`](control::Command)s up,
+//! [`CommandAck`](control::CommandAck)s down (§5).
+//!
+//! Any failure on any of those paths is the single shared
+//! [`ProtocolError`](error::ProtocolError) (§9.8).
+//!
+//! # Deferred (later issues + the doc-reconciliation pass)
+//!
+//! - **Exact delta encodings.** [`Change::Delta`](stream::Change::Delta) is a
+//! `serde_json::Value` placeholder for now; the precise per-projection delta shapes
+//! (a single appended lap, a heat-state transition, …) are pinned when the change
+//! stream lands (#43). The *structure* — per-projection, delta-vs-fresh — is fixed
+//! here, which is what later issues need.
+//! - **Scope addressing grammar.** [`Scope`](scope::Scope) enumerates the four
+//! addressable resources (event/class/heat/pilot, §4); the full filter/grammar and
+//! URL shapes (§9.6) are refined when the snapshot endpoint lands (#42).
+//! - **`LiveRaceState`** ([`snapshot::LiveRaceState`]) is a real-but-minimal
+//! placeholder; #41 fills in live lap/split progress, running order, and on-deck.
+//! - **Auth tokens / sessions.** The credential format (§9.4) is a #44 concern; no
+//! token types live here yet.
#![forbid(unsafe_code)]
+
+pub mod control;
+pub mod error;
+pub mod scope;
+pub mod snapshot;
+pub mod stream;
+
+use serde::{Deserialize, Serialize};
+use ts_rs::TS;
+
+/// The protocol **contract version** — axis 1 of the three version axes
+/// (protocol.html §7): the wire shapes owned by this crate, kept distinct from the
+/// log/schema version and the projection version.
+///
+/// A single monotonic integer. A breaking change to any wire type bumps it; additive
+/// changes (new projections, new fields an older client ignores) do not. The version
+/// is negotiated at connect time ([`Hello`] / [`ServerHello`]) and is independent of
+/// the transport (LAN or Cloud) — see [`CONTRACT_VERSION`] for the version this build
+/// speaks.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)]
+#[serde(transparent)]
+#[ts(export, export_to = "bindings/", as = "u32")]
+pub struct ContractVersion {
+ /// The monotonic contract version number.
+ pub version: u32,
+}
+
+impl ContractVersion {
+ /// Construct from a raw version number.
+ pub const fn new(version: u32) -> Self {
+ Self { version }
+ }
+}
+
+/// The contract version this server build speaks. The first wire contract is `1`.
+pub const CONTRACT_VERSION: ContractVersion = ContractVersion::new(1);
+
+/// The client's **connect message** (protocol.html §7): announced before anything
+/// else, it carries the [`ContractVersion`] the client was built against so the
+/// server can decide whether it can serve it.
+///
+/// "The contract version is negotiated, not assumed" (§7): the client states its
+/// version, the server replies with the band it serves ([`ServerHello`]). The version
+/// is the only thing negotiated here; auth (a bearer token, §9.4) is a separate #44
+/// concern layered in front, not part of this message yet.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub struct Hello {
+ /// The contract version the client was built against.
+ pub contract_version: ContractVersion,
+}
+
+/// The server's reply to a [`Hello`] (protocol.html §7).
+///
+/// The server "states the version(s) it serves" as an inclusive band
+/// `[min_contract_version, max_contract_version]`. A client whose
+/// [`Hello::contract_version`] falls inside the band is served; one that falls
+/// outside gets `compatible = false` — the explicit "too old / too new, please
+/// refresh" signal — rather than malformed data.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub struct ServerHello {
+ /// The oldest contract version this server still serves (inclusive).
+ pub min_contract_version: ContractVersion,
+ /// The newest contract version this server serves (inclusive).
+ pub max_contract_version: ContractVersion,
+ /// Whether the client's announced version falls within the served band. `false`
+ /// is the "please refresh" signal (the client is too old or too new to be served).
+ pub compatible: bool,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn contract_version_is_a_bare_integer_on_the_wire() {
+ // `#[serde(transparent)]`: the version serialises as the raw integer, and
+ // `#[ts(as = "u32")]` mirrors that as a `number` in the generated TS.
+ assert_eq!(
+ serde_json::to_string(&ContractVersion::new(7)).unwrap(),
+ "7"
+ );
+ }
+
+ #[test]
+ fn hello_round_trips() {
+ let hello = Hello {
+ contract_version: CONTRACT_VERSION,
+ };
+ let json = serde_json::to_string(&hello).unwrap();
+ let back: Hello = serde_json::from_str(&json).unwrap();
+ assert_eq!(hello, back);
+ }
+
+ #[test]
+ fn server_hello_round_trips() {
+ let reply = ServerHello {
+ min_contract_version: ContractVersion::new(1),
+ max_contract_version: ContractVersion::new(3),
+ compatible: true,
+ };
+ let json = serde_json::to_string(&reply).unwrap();
+ let back: ServerHello = serde_json::from_str(&json).unwrap();
+ assert_eq!(reply, back);
+ }
+}
diff --git a/crates/server/src/scope.rs b/crates/server/src/scope.rs
new file mode 100644
index 0000000..3ada256
--- /dev/null
+++ b/crates/server/src/scope.rs
@@ -0,0 +1,164 @@
+//! Subscription **scoping** (protocol.html §4).
+//!
+//! A subscription is a [`Scope`] plus a starting [`Cursor`](crate::stream::Cursor):
+//! the scope is how a client asks for exactly the projections it needs — "a phone
+//! fetches one heat, not the whole event" — and bounds both payload size and what a
+//! client is allowed to see. The same [`Scope`] addresses a snapshot read (§2) and a
+//! stream subscription (§3).
+//!
+//! protocol.html §9.6 resolves the *grammar* (a scope grammar with multi-scope
+//! subscribe and cursor pagination) but defers the exact addressing scheme, URL
+//! shapes, and filter expressiveness to implementation. This enum is the **four
+//! addressable resources** §4 names (event / class / heat / pilot); the richer filter
+//! grammar and composition ("a pilot within a class") layer on when the snapshot
+//! endpoint lands (#42).
+
+use gridfpv_events::HeatId;
+use serde::{Deserialize, Serialize};
+use ts_rs::TS;
+
+/// Identifies an **event** — the whole competition (protocol.html §4 "Event scope").
+///
+/// A transparent string newtype like the event-model ids. The cross-event registry
+/// and account model are a Cloud concern (protocol.html callout); this is just the
+/// stable handle a scope addresses, sufficient for the wire contract.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)]
+#[serde(transparent)]
+#[ts(export, export_to = "bindings/")]
+pub struct EventId(pub String);
+
+/// Identifies a **class** within an event (protocol.html §4 "Class scope") — one
+/// class's phases, schedule, and standings, which may run in parallel with others.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)]
+#[serde(transparent)]
+#[ts(export, export_to = "bindings/")]
+pub struct ClassId(pub String);
+
+/// Identifies a **pilot** across an event (protocol.html §4 "Pilot scope") — a racer
+/// following their own laps, results, and next heat.
+///
+/// This is the event-scoped pilot handle a scope addresses, distinct from the
+/// per-source [`CompetitorRef`](gridfpv_events::CompetitorRef) the timer reports: a
+/// pilot is bound to source competitors by a registration action (Architecture §9),
+/// which is out of scope here.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)]
+#[serde(transparent)]
+#[ts(export, export_to = "bindings/")]
+pub struct PilotId(pub String);
+
+/// What a subscription addresses (protocol.html §4): one of the four resources a
+/// client can scope to. Externally tagged like the event model, so it maps to a TS
+/// discriminated union.
+///
+/// Scopes compose and a client may hold several at once over one connection (§4); a
+/// multi-scope subscribe is a list of these. The richer filter grammar (§9.6) is
+/// deferred — this fixes the addressable *resources*, which is what later issues build
+/// the grammar over.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub enum Scope {
+ /// Everything for one event — the broad default for the "whole venue screen" and
+ /// the Cloud live surface.
+ Event {
+ /// The event addressed.
+ event: EventId,
+ },
+ /// One class's phases, schedule, and standings.
+ Class {
+ /// The event the class belongs to.
+ event: EventId,
+ /// The class addressed.
+ class: ClassId,
+ },
+ /// One heat's live race-state and lap lists — the tightest, lowest-latency scope.
+ Heat {
+ /// The heat addressed (the id it carries in the log).
+ heat: HeatId,
+ },
+ /// One pilot across the event — their laps, results, and next heat.
+ Pilot {
+ /// The event the pilot is racing in.
+ event: EventId,
+ /// The pilot addressed.
+ pilot: PilotId,
+ },
+}
+
+/// A subscription request (protocol.html §3, §4): a [`Scope`] plus where to resume
+/// the stream from.
+///
+/// `from` is the last-applied [`Cursor`](crate::stream::Cursor) the client presents on
+/// (re)connect — `Some(cursor)` to resume after a drop, `None` for a fresh
+/// subscription that begins from the snapshot's cursor (protocol.html §3 "resume by
+/// cursor, fall back to re-snapshot"). If the gap is too old to replay the server
+/// answers with a [`StaleCursor`](crate::error::ErrorCode::StaleCursor) error and the
+/// client re-snapshots.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub struct SubscribeRequest {
+ /// The resource this subscription is scoped to.
+ pub scope: Scope,
+ /// The cursor to resume after, or `None` to start fresh from the snapshot.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[ts(optional)]
+ pub from: Option,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::stream::Cursor;
+
+ #[test]
+ fn every_scope_variant_round_trips() {
+ let scopes = vec![
+ Scope::Event {
+ event: EventId("spring-cup".into()),
+ },
+ Scope::Class {
+ event: EventId("spring-cup".into()),
+ class: ClassId("open".into()),
+ },
+ Scope::Heat {
+ heat: HeatId("q-1".into()),
+ },
+ Scope::Pilot {
+ event: EventId("spring-cup".into()),
+ pilot: PilotId("acroace".into()),
+ },
+ ];
+ for scope in scopes {
+ let json = serde_json::to_string(&scope).unwrap();
+ let back: Scope = serde_json::from_str(&json).unwrap();
+ assert_eq!(scope, back);
+ }
+ }
+
+ #[test]
+ fn subscribe_request_round_trips_with_and_without_cursor() {
+ let fresh = SubscribeRequest {
+ scope: Scope::Heat {
+ heat: HeatId("q-1".into()),
+ },
+ from: None,
+ };
+ let json = serde_json::to_string(&fresh).unwrap();
+ // A fresh subscribe omits `from` entirely (skip_serializing_if).
+ assert!(
+ !json.contains("from"),
+ "fresh subscribe omits cursor: {json}"
+ );
+ let back: SubscribeRequest = serde_json::from_str(&json).unwrap();
+ assert_eq!(fresh, back);
+
+ let resume = SubscribeRequest {
+ scope: Scope::Event {
+ event: EventId("spring-cup".into()),
+ },
+ from: Some(Cursor::new(42)),
+ };
+ let json = serde_json::to_string(&resume).unwrap();
+ let back: SubscribeRequest = serde_json::from_str(&json).unwrap();
+ assert_eq!(resume, back);
+ }
+}
diff --git a/crates/server/src/snapshot.rs b/crates/server/src/snapshot.rs
new file mode 100644
index 0000000..653d937
--- /dev/null
+++ b/crates/server/src/snapshot.rs
@@ -0,0 +1,215 @@
+//! The snapshot read model (protocol.html §2) and the served projection bodies (§1).
+//!
+//! A client begins by fetching a [`Snapshot`]: the current materialized state of the
+//! projection it scoped to, plus the [`Cursor`](crate::stream::Cursor) the change
+//! stream resumes from. "Snapshot first, then subscribe" — the snapshot is
+//! self-contained and immediately renderable, and its cursor lets the stream start
+//! exactly where the snapshot ended so nothing is missed or double-applied (§2, §3).
+//!
+//! # What the protocol serves — projections, not the log (§1)
+//!
+//! The wire never carries the raw event log; it carries **projections** — the answers
+//! derived by folding the log. [`ProjectionBody`] is the closed set of served
+//! projection shapes, each reusing the *existing* projection/engine output type rather
+//! than redefining it (protocol.html §1: this doc "does not redefine the projections
+//! themselves"):
+//!
+//! - [`LiveRaceState`] — the latency-sensitive live core (a #41 placeholder here);
+//! - [`LapList`](gridfpv_projection::LapList) — per-pilot laps, marshaling already
+//! folded in;
+//! - [`HeatResult`](gridfpv_engine::scoring::HeatResult) — the scored heat outcome;
+//! - ranking — a qualifying / bracket [`RankEntry`](gridfpv_engine::format::RankEntry)
+//! list (the provisional-or-final ordering a format exposes);
+//! - [`EventOutcome`](gridfpv_engine::event::EventOutcome) — the full-event standings.
+//!
+//! [`ProjectionKind`] is the bare discriminant (which projection, no body) — what a
+//! [`ChangeEnvelope`](crate::stream::ChangeEnvelope) names when it carries a *delta*
+//! rather than a fresh value.
+
+use gridfpv_engine::event::EventOutcome;
+use gridfpv_engine::format::RankEntry;
+use gridfpv_engine::scoring::HeatResult;
+use gridfpv_events::HeatId;
+use gridfpv_projection::LapList;
+use serde::{Deserialize, Serialize};
+use ts_rs::TS;
+
+use crate::stream::Cursor;
+
+/// The current **live race-state** projection (protocol.html §1) — the latency-sensitive
+/// core every overlay and spectator watches: the current heat and its loop state, the
+/// active pilots' live lap/split progress, the running order, and the on-deck heat.
+///
+/// **#41 placeholder.** This is intentionally minimal-but-real: it carries the current
+/// heat id and a coarse [`HeatPhase`] so the contract, the snapshot body, and the
+/// change envelope are all wired end to end *now*. #41 fleshes it out with per-pilot
+/// live progress, the running order, and the on-deck heat. Keeping the type real (not a
+/// unit stub) means #41 extends fields additively (§7) without reshaping the envelope.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub struct LiveRaceState {
+ /// The heat currently on the timer, if any (`None` between heats).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[ts(optional)]
+ pub current_heat: Option,
+ /// The current heat's loop phase (protocol.html §1, race-engine.html §2).
+ pub phase: HeatPhase,
+}
+
+/// The heat-loop phase the live state reports (protocol.html §1: `Scheduled → Staged →
+/// Armed → Running → Landed → Scored`).
+///
+/// This is the *projected* view of the heat loop — the folded current phase a client
+/// renders — not the raw [`HeatTransition`](gridfpv_events::HeatTransition) event the
+/// engine appends. The off-ramp transitions (abort/restart/discard) resolve back onto
+/// one of these phases, so the live view stays a simple linear status. A #41-era detail
+/// the placeholder pins minimally.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub enum HeatPhase {
+ /// The heat exists with a lineup but has not begun.
+ Scheduled,
+ /// Countdown begun; frequencies assigned.
+ Staged,
+ /// The gate is open to detections.
+ Armed,
+ /// The race is running; passes are being consumed.
+ Running,
+ /// The race has closed — time elapsed or all landed — but is not yet scored.
+ Landed,
+ /// The result is finalized.
+ Scored,
+}
+
+/// Which projection a snapshot body or change envelope is *about* — the bare
+/// discriminant with no payload (protocol.html §3).
+///
+/// A fresh-value envelope carries the whole [`ProjectionBody`] (kind *and* value); a
+/// delta envelope carries this `ProjectionKind` plus an opaque delta, naming the
+/// projection it advances without re-sending it. Keeping the kind separate from the
+/// body lets a delta name its target cheaply.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub enum ProjectionKind {
+ /// The live race-state ([`LiveRaceState`]).
+ LiveRaceState,
+ /// A lap list ([`LapList`](gridfpv_projection::LapList)).
+ LapList,
+ /// A scored heat result ([`HeatResult`](gridfpv_engine::scoring::HeatResult)).
+ HeatResult,
+ /// A qualifying / bracket ranking (a [`RankEntry`](gridfpv_engine::format::RankEntry) list).
+ Ranking,
+ /// The full-event outcome / standings ([`EventOutcome`](gridfpv_engine::event::EventOutcome)).
+ EventOutcome,
+}
+
+/// A served projection **with its value** (protocol.html §1) — the closed set of
+/// projection shapes the contract carries, each reusing the existing projection/engine
+/// output type. Externally tagged, so it maps to a TS discriminated union keyed by
+/// projection kind.
+///
+/// This is the body of a [`Snapshot`] and of a fresh-value
+/// [`ChangeEnvelope`](crate::stream::ChangeEnvelope). Adding a new served projection is
+/// an additive variant (§7); an older client ignores a kind it does not understand.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub enum ProjectionBody {
+ /// The live race-state (protocol.html §1, the latency-sensitive core).
+ LiveRaceState(LiveRaceState),
+ /// A per-pilot lap list, with marshaling adjudications already folded in.
+ LapList(LapList),
+ /// A scored heat result — finishing order, lap counts, deciding metrics.
+ HeatResult(HeatResult),
+ /// A qualifying or bracket ranking (best first; provisional mid-format, final once
+ /// the format completes).
+ Ranking(Vec),
+ /// The full-event outcome: the qualifying ranking, bracket standings, and winner.
+ EventOutcome(EventOutcome),
+}
+
+impl ProjectionBody {
+ /// The [`ProjectionKind`] discriminant for this body — what a delta envelope names.
+ pub fn kind(&self) -> ProjectionKind {
+ match self {
+ ProjectionBody::LiveRaceState(_) => ProjectionKind::LiveRaceState,
+ ProjectionBody::LapList(_) => ProjectionKind::LapList,
+ ProjectionBody::HeatResult(_) => ProjectionKind::HeatResult,
+ ProjectionBody::Ranking(_) => ProjectionKind::Ranking,
+ ProjectionBody::EventOutcome(_) => ProjectionKind::EventOutcome,
+ }
+ }
+}
+
+/// A snapshot of a scoped projection (protocol.html §2): the current materialized
+/// [`ProjectionBody`] plus the [`Cursor`] the change stream resumes from.
+///
+/// "Snapshot first, then subscribe" (§2): the client renders the `body` immediately and
+/// opens the stream `from` this `cursor`, so the first envelope it applies is exactly
+/// the one after the snapshot — nothing missed, nothing double-applied. Re-snapshotting
+/// is always correct because projections are recomputable; the stream is an
+/// optimization, never the source of truth (§3).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub struct Snapshot {
+ /// The stream cursor this snapshot was taken at — where the subscription resumes.
+ pub cursor: Cursor,
+ /// The materialized projection at that cursor.
+ pub body: ProjectionBody,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sample_live() -> ProjectionBody {
+ ProjectionBody::LiveRaceState(LiveRaceState {
+ current_heat: Some(HeatId("q-1".into())),
+ phase: HeatPhase::Running,
+ })
+ }
+
+ #[test]
+ fn snapshot_round_trips() {
+ let snap = Snapshot {
+ cursor: Cursor::new(7),
+ body: sample_live(),
+ };
+ let json = serde_json::to_string(&snap).unwrap();
+ let back: Snapshot = serde_json::from_str(&json).unwrap();
+ assert_eq!(snap, back);
+ }
+
+ #[test]
+ fn empty_lap_list_body_round_trips() {
+ let snap = Snapshot {
+ cursor: Cursor::new(0),
+ body: ProjectionBody::LapList(LapList::default()),
+ };
+ let json = serde_json::to_string(&snap).unwrap();
+ let back: Snapshot = serde_json::from_str(&json).unwrap();
+ assert_eq!(snap, back);
+ }
+
+ #[test]
+ fn body_kind_matches_variant() {
+ assert_eq!(sample_live().kind(), ProjectionKind::LiveRaceState);
+ assert_eq!(
+ ProjectionBody::Ranking(vec![]).kind(),
+ ProjectionKind::Ranking
+ );
+ assert_eq!(
+ ProjectionBody::LapList(LapList::default()).kind(),
+ ProjectionKind::LapList
+ );
+ }
+
+ #[test]
+ fn projection_kind_round_trips() {
+ use ProjectionKind::*;
+ for kind in [LiveRaceState, LapList, HeatResult, Ranking, EventOutcome] {
+ let json = serde_json::to_string(&kind).unwrap();
+ let back: ProjectionKind = serde_json::from_str(&json).unwrap();
+ assert_eq!(kind, back);
+ }
+ }
+}
diff --git a/crates/server/src/stream.rs b/crates/server/src/stream.rs
new file mode 100644
index 0000000..66b4086
--- /dev/null
+++ b/crates/server/src/stream.rs
@@ -0,0 +1,142 @@
+//! The realtime change stream (protocol.html §3): the per-stream [`Cursor`] and the
+//! [`ChangeEnvelope`]s that advance a client's projections after the snapshot.
+//!
+//! After fetching a [`Snapshot`](crate::snapshot::Snapshot), the client opens a
+//! WebSocket and receives an ordered run of change envelopes. "Snapshot + incremental
+//! changes" (§3): the stream sends **deltas, not repeated full state** — a new lap, a
+//! heat-state transition, a re-scored result — and may collapse a burst into a single
+//! fresh-value envelope when a delta would be larger or ambiguous (e.g. after a
+//! marshaling re-fold of a whole lap list).
+//!
+//! Each envelope names the projection it touches (via the
+//! [`ProjectionKind`](crate::snapshot::ProjectionKind) for a delta, or the
+//! [`ProjectionBody`](crate::snapshot::ProjectionBody) itself for a fresh value) and
+//! carries the monotonic [`Cursor`] the client applies in order.
+
+use serde::{Deserialize, Serialize};
+use ts_rs::TS;
+
+use crate::snapshot::{ProjectionBody, ProjectionKind};
+
+/// The public **projection sequence number** (protocol.html §3, §9.5): a single
+/// monotonic integer per stream, increasing by one per envelope.
+///
+/// It is *not* the raw log offset — one log append can fan out into several projection
+/// changes or none a client subscribes to. The protocol commits to this projection
+/// sequence as its public ordering; the log offset stays a private Director/Cloud
+/// detail. The snapshot hands the client a starting cursor (§2) and every envelope
+/// advances it; on reconnect the client presents its last-applied cursor to resume
+/// (§3).
+///
+/// Transparent `u64` newtype; `#[ts(as = "bigint")]` so it renders as a TS `bigint`
+/// (a `u64` exceeds JS's safe-integer range), matching how it serialises.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)]
+#[serde(transparent)]
+#[ts(export, export_to = "bindings/", as = "u64")]
+pub struct Cursor {
+ /// The monotonic per-stream sequence value.
+ pub seq: u64,
+}
+
+impl Cursor {
+ /// Construct a cursor from a raw sequence value.
+ pub const fn new(seq: u64) -> Self {
+ Self { seq }
+ }
+
+ /// The next cursor in sequence (`seq + 1`) — the envelope that follows this one.
+ pub const fn next(self) -> Self {
+ Self { seq: self.seq + 1 }
+ }
+}
+
+/// How an envelope conveys a projection change (protocol.html §3, §9.2): a **delta**
+/// for append-heavy projections (lap lists, live state) or a **fresh value** for cheap
+/// or re-folded ones (a heat result, a ranking after a marshaling correction).
+///
+/// Externally tagged, mapping to a TS discriminated union.
+///
+/// > **Deferred (#43):** the exact per-projection delta *encodings* are a placeholder.
+/// > [`Change::Delta`] carries a `serde_json::Value` rather than a typed delta for each
+/// > [`ProjectionKind`]; the precise delta shapes (a single appended lap, a heat-state
+/// > transition) are pinned when the change stream lands. The *structure* — the
+/// > per-projection delta-vs-fresh-value distinction protocol.html §9.2 fixes — is
+/// > what matters now and is captured here. A fresh value, by contrast, is already a
+/// > fully-typed [`ProjectionBody`].
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub enum Change {
+ /// An incremental delta against the projection named by the envelope's
+ /// [`ChangeEnvelope::projection`]. Placeholder encoding (`serde_json::Value`) until
+ /// #43 pins the typed per-projection delta shapes.
+ Delta(#[ts(type = "unknown")] serde_json::Value),
+ /// The projection's complete new value — used when a delta would be larger or
+ /// ambiguous (a marshaling re-fold, a cheap projection). Fully typed already.
+ FreshValue(ProjectionBody),
+}
+
+/// A change envelope (protocol.html §3): one ordered, sequenced update to a single
+/// projection.
+///
+/// Each envelope carries its monotonic `sequence` [`Cursor`], names the `projection` it
+/// touches (as a [`ProjectionKind`] — for a [`Change::FreshValue`] the body also
+/// carries the kind, but naming it here keeps deltas and fresh values uniform), and a
+/// `change` that is a delta or a fresh value. The client applies envelopes in strictly
+/// increasing `sequence` order; re-delivering one already applied is a no-op keyed by
+/// sequence (§3 "idempotent application", "at-least-once, deduplicated").
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub struct ChangeEnvelope {
+ /// This envelope's position in the per-stream sequence.
+ pub sequence: Cursor,
+ /// Which projection this change advances.
+ pub projection: ProjectionKind,
+ /// The delta or fresh value to apply.
+ pub change: Change,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::snapshot::{HeatPhase, LiveRaceState};
+ use gridfpv_events::HeatId;
+
+ #[test]
+ fn cursor_is_a_bare_integer_on_the_wire() {
+ // Transparent newtype: serialises as the raw sequence integer.
+ assert_eq!(serde_json::to_string(&Cursor::new(42)).unwrap(), "42");
+ }
+
+ #[test]
+ fn cursor_next_increments() {
+ assert_eq!(Cursor::new(5).next(), Cursor::new(6));
+ }
+
+ #[test]
+ fn fresh_value_envelope_round_trips() {
+ let env = ChangeEnvelope {
+ sequence: Cursor::new(101),
+ projection: ProjectionKind::LiveRaceState,
+ change: Change::FreshValue(ProjectionBody::LiveRaceState(LiveRaceState {
+ current_heat: Some(HeatId("q-1".into())),
+ phase: HeatPhase::Running,
+ })),
+ };
+ let json = serde_json::to_string(&env).unwrap();
+ let back: ChangeEnvelope = serde_json::from_str(&json).unwrap();
+ assert_eq!(env, back);
+ }
+
+ #[test]
+ fn delta_envelope_round_trips_with_placeholder_payload() {
+ // The delta is an opaque JSON value until #43 pins typed per-projection deltas.
+ let env = ChangeEnvelope {
+ sequence: Cursor::new(102),
+ projection: ProjectionKind::LapList,
+ change: Change::Delta(serde_json::json!({ "appended_lap": { "number": 3 } })),
+ };
+ let json = serde_json::to_string(&env).unwrap();
+ let back: ChangeEnvelope = serde_json::from_str(&json).unwrap();
+ assert_eq!(env, back);
+ }
+}
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index c74f6fa..084d79b 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -66,22 +66,21 @@ fn test() -> bool {
)
}
-/// Regenerate the Rust→TypeScript bindings (#4).
+/// Regenerate the Rust→TypeScript bindings (#4, #40).
///
/// ts-rs exports its types from a generated `#[test]` per `#[ts(export)]` type, so
-/// "generation" is just running the events crate's export tests: each derived `TS`
-/// impl writes its `.ts` file. `TS_RS_EXPORT_DIR` pins the base directory to the
-/// workspace root, so every `export_to = "bindings/"` lands in `/bindings/`.
+/// "generation" is just running the export tests of every crate that derives `TS`:
+/// each derived impl writes its `.ts` file. The wire contract now spans four crates —
+/// the event model (`gridfpv-events`), the served lap projection (`gridfpv-projection`),
+/// the heat results / rankings / event outcome (`gridfpv-engine`), and the protocol
+/// wire types themselves (`gridfpv-server`) — so we run the `export_bindings` filter
+/// across the whole workspace. `TS_RS_EXPORT_DIR` pins the base directory to the
+/// workspace root, so every `export_to = "bindings/"` lands in `/bindings/`
+/// regardless of which crate the type lives in.
fn gen_bindings(root: &Path) -> bool {
run_env(
"cargo",
- &[
- "test",
- "--package",
- "gridfpv-events",
- "--quiet",
- "export_bindings",
- ],
+ &["test", "--workspace", "--quiet", "export_bindings"],
&[("TS_RS_EXPORT_DIR", root)],
)
}
From edc5ae7c6b99047e11c1154a82fecd0ce2265008 Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 03:54:59 +0000
Subject: [PATCH 004/362] Add live race-state projection and axum snapshot
endpoints (#41, #42)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Flesh out the live race-state projection (#41) and stand up the axum
snapshot HTTP transport over the wire types (#42) — the protocol server's
READ foundation.
#41 live race-state projection (crates/server/src/live_state.rs):
- `LiveRaceState` grows additively over the #40 placeholder: current heat +
HeatPhase, active pilots (lineup), per-pilot `PilotProgress` (laps + last
lap), provisional running order, and the on-deck heat.
- `live_state(events: &[Event]) -> LiveRaceState` is a pure fold of the log:
current heat = most-recently-active heat, phase via `heat::heat_state`,
progress via the marshaling-aware lap projection, running order by
most-laps-then-earliest-last-lap. Re-exported from `snapshot` so
`ProjectionBody::LiveRaceState` still names a snapshot-module type.
#42 axum snapshot endpoints + scope addressing (crates/server/src/app.rs):
- `AppState` holds the shared append-only log behind `Arc>`; `EventSource` is an object-safe facade over
`EventLog` (whose generic `append_batch` is not dyn-compatible),
blanket-impl'd for every backend. The WS stream (#43) and control path
(#45) share this same handle (read tail / append exposed already).
- `router(state) -> axum::Router` serves a snapshot per scope:
GET /snapshot/event/{event}, /snapshot/class/{event}/{class},
/snapshot/heat/{heat} (?projection=live|laps|result),
/snapshot/pilot/{event}/{pilot}. Each returns `Snapshot { cursor, body }`
where cursor = log length at read time (the resume point) and body = the
scoped projection. The heat scope filters the log to the heat's window.
- `ProtocolError` gains an axum `IntoResponse` mapping code -> HTTP status.
Tests: live_state unit tests (Scheduled..Scored progression, lap progress,
running order, on-deck) + axum integration tests via tower oneshot (no
network/Docker) asserting each scope's body + cursor against an InMemoryLog.
Regenerated bindings (LiveRaceState.ts, new PilotProgress.ts). axum + tokio
added to the server crate's own Cargo.toml. `cargo xtask ci` is green.
Part of #41, #42.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
Cargo.lock | 95 +++++
bindings/LiveRaceState.ts | 41 +-
bindings/PilotProgress.ts | 24 ++
crates/server/Cargo.toml | 11 +
crates/server/src/app.rs | 677 ++++++++++++++++++++++++++++++++
crates/server/src/lib.rs | 28 +-
crates/server/src/live_state.rs | 439 +++++++++++++++++++++
crates/server/src/snapshot.rs | 26 +-
crates/server/src/stream.rs | 1 +
9 files changed, 1299 insertions(+), 43 deletions(-)
create mode 100644 bindings/PilotProgress.ts
create mode 100644 crates/server/src/app.rs
create mode 100644 crates/server/src/live_state.rs
diff --git a/Cargo.lock b/Cargo.lock
index 2731ebc..0a87e71 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -47,6 +47,58 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
[[package]]
name = "backoff"
version = "0.4.0"
@@ -441,11 +493,16 @@ dependencies = [
name = "gridfpv-server"
version = "0.1.0"
dependencies = [
+ "axum",
"gridfpv-engine",
"gridfpv-events",
"gridfpv-projection",
+ "gridfpv-storage",
+ "http-body-util",
"serde",
"serde_json",
+ "tokio",
+ "tower",
"ts-rs",
]
@@ -549,6 +606,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
[[package]]
name = "hyper"
version = "1.10.1"
@@ -563,6 +626,7 @@ dependencies = [
"http",
"http-body",
"httparse",
+ "httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@@ -805,6 +869,12 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
[[package]]
name = "memchr"
version = "2.8.2"
@@ -1279,6 +1349,17 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -1489,9 +1570,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
+ "tokio-macros",
"windows-sys 0.61.2",
]
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
@@ -1552,6 +1645,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
+ "tracing",
]
[[package]]
@@ -1590,6 +1684,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
+ "log",
"pin-project-lite",
"tracing-core",
]
diff --git a/bindings/LiveRaceState.ts b/bindings/LiveRaceState.ts
index 57d8b82..137e5b8 100644
--- a/bindings/LiveRaceState.ts
+++ b/bindings/LiveRaceState.ts
@@ -1,24 +1,45 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { CompetitorRef } from "./CompetitorRef";
import type { HeatId } from "./HeatId";
import type { HeatPhase } from "./HeatPhase";
+import type { PilotProgress } from "./PilotProgress";
/**
* The current **live race-state** projection (protocol.html §1) — the latency-sensitive
- * core every overlay and spectator watches: the current heat and its loop state, the
- * active pilots' live lap/split progress, the running order, and the on-deck heat.
+ * core every overlay and spectator watches.
*
- * **#41 placeholder.** This is intentionally minimal-but-real: it carries the current
- * heat id and a coarse [`HeatPhase`] so the contract, the snapshot body, and the
- * change envelope are all wired end to end *now*. #41 fleshes it out with per-pilot
- * live progress, the running order, and the on-deck heat. Keeping the type real (not a
- * unit stub) means #41 extends fields additively (§7) without reshaping the envelope.
+ * Fleshed out in #41 from the #40 placeholder: it carries the current heat and its
+ * [`HeatPhase`], the active pilots in that heat, each pilot's live
+ * [`PilotProgress`], the running order, and the on-deck heat. Fields are additive over
+ * the placeholder (§7), so the snapshot body and change envelope did not reshape.
*/
export type LiveRaceState = {
/**
- * The heat currently on the timer, if any (`None` between heats).
+ * The heat currently on the timer, if any (`None` before any heat is scheduled).
*/
current_heat?: HeatId,
/**
- * The current heat's loop phase (protocol.html §1, race-engine.html §2).
+ * The current heat's loop phase (protocol.html §1, race-engine.html §2). When
+ * `current_heat` is `None` this is [`HeatPhase::Scheduled`] (the idle default).
*/
-phase: HeatPhase, };
+phase: HeatPhase,
+/**
+ * The active pilots in the current heat — its lineup, in lineup (seeding) order.
+ * Empty when there is no current heat.
+ */
+active_pilots?: Array,
+/**
+ * Per-pilot live lap progress for the current heat, one entry per active pilot,
+ * ordered like [`active_pilots`](Self::active_pilots).
+ */
+progress?: Array,
+/**
+ * The provisional running order of the current heat: the active pilots ranked by
+ * live standing (most laps, then who banked their last lap earliest). Best first.
+ */
+running_order?: Array,
+/**
+ * The next heat to run after the current one (the earliest still-`Scheduled` heat
+ * that is not on the timer), if one is known.
+ */
+on_deck?: HeatId, };
diff --git a/bindings/PilotProgress.ts b/bindings/PilotProgress.ts
new file mode 100644
index 0000000..057a9c3
--- /dev/null
+++ b/bindings/PilotProgress.ts
@@ -0,0 +1,24 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { CompetitorRef } from "./CompetitorRef";
+
+/**
+ * One active pilot's live progress in the current heat (protocol.html §1).
+ *
+ * Derived from the heat's lap projection: the number of laps completed so far and the
+ * duration of the most recent completed lap (the live "last lap" an overlay shows).
+ * Splits are a later refinement; the lap-count + last-lap pair is the live core.
+ */
+export type PilotProgress = {
+/**
+ * The source-local competitor this progress is for (a member of the lineup).
+ */
+competitor: CompetitorRef,
+/**
+ * Completed laps so far in the heat.
+ */
+laps_completed: number,
+/**
+ * Duration (µs, source clock) of the most recently completed lap, or `None` before
+ * the pilot has completed a lap.
+ */
+last_lap_micros?: bigint, };
diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml
index b04e293..fc68248 100644
--- a/crates/server/Cargo.toml
+++ b/crates/server/Cargo.toml
@@ -10,8 +10,19 @@ rust-version.workspace = true
gridfpv-events.workspace = true
gridfpv-projection.workspace = true
gridfpv-engine.workspace = true
+gridfpv-storage.workspace = true
serde.workspace = true
serde_json.workspace = true
ts-rs = "12"
+# The snapshot HTTP transport (#42). The WS change stream (#43) and the control path
+# (#45) build on the same axum `Router` and `AppState` defined in `app.rs`.
+axum = "0.8"
+tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "macros", "net"] }
+
[dev-dependencies]
+# `tower::ServiceExt::oneshot` drives the router in integration tests with no real
+# network or Docker; `http-body-util` collects the response body to assert on it.
+tower = { version = "0.5", features = ["util"] }
+http-body-util = "0.1"
+tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs
new file mode 100644
index 0000000..9821cad
--- /dev/null
+++ b/crates/server/src/app.rs
@@ -0,0 +1,677 @@
+//! The axum snapshot HTTP transport (protocol.html §2, §4) — issue #42.
+//!
+//! This is the first real transport over the wire types: a [`Router`] of `GET` snapshot
+//! endpoints, one addressing scheme per [`Scope`], each returning a [`Snapshot`] of the
+//! scoped projection plus the [`Cursor`] the change stream resumes from. "Snapshot first,
+//! then subscribe" (§2): the client renders the body immediately and opens the WS stream
+//! (#43) *from* the returned cursor.
+//!
+//! # [`AppState`] — the shared event source
+//!
+//! Every path the server serves reads (and the control path #45 writes) the **one**
+//! append-only [`EventLog`]. [`AppState`] wraps it in an `Arc>` so it can be
+//! cloned into every axum handler and, later, shared with the WS stream task and the
+//! control handler:
+//!
+//! - **Reads** (this issue): a handler locks, reads the log into a `Vec`, folds
+//! the requested projection, and unlocks. The cursor is the log length at read time
+//! (see below).
+//! - **Writes** (#45): the control handler will lock and `append` through the same
+//! handle, and the change stream (#43) will observe the new tail. Holding the log
+//! behind a single mutex keeps reads and the eventual writes serialized against one
+//! another with no torn state.
+//!
+//! The log is stored as `Arc>` so any backend
+//! ([`InMemoryLog`](gridfpv_storage::InMemoryLog),
+//! [`SqliteLog`](gridfpv_storage::SqliteLog)) drops in unchanged.
+//!
+//! # Cursor semantics
+//!
+//! The [`Cursor`] a snapshot returns is **the log length at read time** — i.e. the offset
+//! the *next* appended event will receive (`EventLog::len`). That is exactly the resume
+//! point: a subscription opened `from` this cursor begins at the first event appended
+//! after the snapshot, so nothing is missed or double-applied (§2, §3). The public
+//! projection-sequence cursor the WS stream advances (#43) is seeded from this value.
+//! (Mapping log offsets to the per-projection sequence number §9.5 is a #43 concern; for
+//! the snapshot the log length *is* the resume cursor.)
+//!
+//! # Scope addressing (protocol.html §4, §9.6)
+//!
+//! §4 fixes the four addressable resources (event / class / heat / pilot); §9.6 defers the
+//! exact URL grammar to implementation. This module pins a concrete REST addressing over
+//! those four, which the doc-reconciliation pass refines:
+//!
+//! | scope | route | body |
+//! |-------|-------|------|
+//! | event | `GET /snapshot/event/{event}` | [`LiveRaceState`] over the whole log |
+//! | class | `GET /snapshot/class/{event}/{class}` | [`LiveRaceState`] (class filtering deferred — see below) |
+//! | heat | `GET /snapshot/heat/{heat}` | [`LiveRaceState`] for that heat, or — with `?projection=laps` / `?projection=result` — its [`LapList`] / [`HeatResult`] |
+//! | pilot | `GET /snapshot/pilot/{event}/{pilot}` | the pilot's [`LapList`] (their laps across the event) |
+//!
+//! A single connection may hold several scopes at once (§4); the multi-scope *subscribe*
+//! is a stream concern (#43). The heat scope is the tightest, lowest-latency one and the
+//! one with a precise log filter; the broader event/class scopes fold the whole log.
+//!
+//! ## Deferred filtering (model gaps, not protocol gaps)
+//!
+//! The raw event log (`gridfpv-events`) has **no event-id or class-id** on its events —
+//! one Director serves one event, and the class→heat and pilot→competitor mappings are
+//! scheduler / registration concerns (#36, Architecture §9) not yet in the log. So:
+//!
+//! - **Event scope** serves the whole log's live state (correct: the log *is* one event).
+//! - **Class scope** is addressable and serves live state, but cannot yet *filter* the log
+//! to one class — there is no class tag to filter on. It returns the same whole-event
+//! live state; precise class filtering lands when the schedule model carries class tags.
+//! - **Pilot scope** filters the lap projection to the competitor whose ref equals the
+//! `pilot` id (the registration binding that maps a `PilotId` to source competitors is
+//! out of scope here, Architecture §9; until it lands the pilot id is matched against the
+//! competitor ref directly).
+//!
+//! These are noted so #43/#44/#45 build on a stable addressing surface while the log-level
+//! filters are tightened later.
+
+use std::sync::{Arc, Mutex};
+
+use axum::Json;
+use axum::Router;
+use axum::extract::{Path, Query, State};
+use axum::http::StatusCode;
+use axum::response::{IntoResponse, Response};
+use axum::routing::get;
+use gridfpv_engine::scoring::{WinCondition, score_events};
+use gridfpv_events::{CompetitorRef, Event, HeatId, SourceTime};
+use gridfpv_projection::{LapList, lap_list_marshaled};
+use gridfpv_storage::{EventLog, Offset, Result as StorageResult, StoredEvent};
+use serde::Deserialize;
+
+use crate::error::{ErrorCode, ProtocolError};
+use crate::live_state::live_state;
+use crate::scope::{ClassId, EventId, PilotId};
+use crate::snapshot::{ProjectionBody, Snapshot};
+use crate::stream::Cursor;
+
+/// The object-safe slice of [`EventLog`] the protocol transport needs: read the whole
+/// log, read its tail, append, and report its length.
+///
+/// [`EventLog`] itself is **not** dyn-compatible (its `append_batch` is generic over the
+/// iterator type), so it cannot be stored behind a trait object directly. This facade
+/// exposes exactly the operations the snapshot reads (and the future control writes, #45,
+/// and stream tail, #43) need, and is blanket-implemented for every `EventLog`, so any
+/// backend ([`InMemoryLog`](gridfpv_storage::InMemoryLog),
+/// [`SqliteLog`](gridfpv_storage::SqliteLog)) drops in behind one `Arc>`.
+pub trait EventSource {
+ /// Read every entry in append order (the snapshot folds these into a projection).
+ fn read_all(&self) -> StorageResult>;
+ /// Read every entry from `start` (inclusive) to the end — the WS stream tail (#43).
+ fn read_from(&self, start: Offset) -> StorageResult>;
+ /// Append a single event, returning its assigned offset — the control path (#45).
+ fn append(&mut self, event: Event, recorded_at: Option) -> StorageResult;
+ /// The number of entries — equivalently the offset the next append receives, which is
+ /// the snapshot resume [`Cursor`].
+ fn len(&self) -> StorageResult;
+ /// Whether the log has no entries.
+ fn is_empty(&self) -> StorageResult {
+ Ok(self.len()? == 0)
+ }
+}
+
+impl EventSource for L {
+ fn read_all(&self) -> StorageResult> {
+ EventLog::read_all(self)
+ }
+ fn read_from(&self, start: Offset) -> StorageResult> {
+ EventLog::read_from(self, start)
+ }
+ fn append(&mut self, event: Event, recorded_at: Option) -> StorageResult {
+ EventLog::append(self, event, recorded_at)
+ }
+ fn len(&self) -> StorageResult {
+ EventLog::len(self)
+ }
+}
+
+/// A thread-safe handle to the one append-only event log every protocol path shares.
+///
+/// `Send` (not `Send + Sync`) is required on the trait object because it lives behind a
+/// [`Mutex`]; the `Arc>` makes the whole handle `Send + Sync` so axum can store
+/// it in [`AppState`] and clone it into every handler. The object-safe [`EventSource`]
+/// facade lets any [`EventLog`] backend sit behind it.
+pub type SharedLog = Arc>;
+
+/// The shared application state every axum handler is given (protocol.html §2).
+///
+/// Holds the [`SharedLog`] — the single source of truth the snapshot reads fold, the WS
+/// stream (#43) tails, and the control path (#45) appends through. Cloning an `AppState`
+/// clones the `Arc`, so all handlers and future tasks share one log.
+#[derive(Clone)]
+pub struct AppState {
+ log: SharedLog,
+}
+
+impl AppState {
+ /// Build the state from a concrete log backend (e.g. an
+ /// [`InMemoryLog`](gridfpv_storage::InMemoryLog) or
+ /// [`SqliteLog`](gridfpv_storage::SqliteLog)).
+ pub fn new(log: impl EventLog + Send + 'static) -> Self {
+ Self {
+ log: Arc::new(Mutex::new(log)),
+ }
+ }
+
+ /// Build the state from an already-shared log handle — for when the WS stream (#43)
+ /// or control path (#45) needs to share the *same* `Arc>` with the router.
+ pub fn from_shared(log: SharedLog) -> Self {
+ Self { log }
+ }
+
+ /// The shared log handle, for tasks that tail or append outside the router.
+ pub fn log(&self) -> SharedLog {
+ Arc::clone(&self.log)
+ }
+
+ /// Read the whole log into a `Vec` plus the resume [`Cursor`] (the log length
+ /// at read time). A single lock spans the read so the events and the cursor are
+ /// consistent with one another.
+ fn read(&self) -> Result<(Vec, Cursor), ProtocolError> {
+ let log = self.log.lock().map_err(|_| {
+ ProtocolError::new(ErrorCode::Internal, "the event log lock was poisoned")
+ })?;
+ let stored = log
+ .read_all()
+ .map_err(|e| ProtocolError::new(ErrorCode::Internal, e.to_string()))?;
+ let cursor = Cursor::new(
+ log.len()
+ .map_err(|e| ProtocolError::new(ErrorCode::Internal, e.to_string()))?,
+ );
+ let events = stored.into_iter().map(|s| s.event).collect();
+ Ok((events, cursor))
+ }
+}
+
+/// Build the snapshot [`Router`] (protocol.html §2, §4) over the shared [`AppState`].
+///
+/// The four scope addressing schemes (see the module docs) plus a liveness `GET /health`.
+/// The WS stream (#43), auth middleware (#44), and control routes (#45) layer onto this
+/// same router and state.
+pub fn router(state: AppState) -> Router {
+ Router::new()
+ .route("/health", get(|| async { "ok" }))
+ .route("/snapshot/event/{event}", get(snapshot_event))
+ .route("/snapshot/class/{event}/{class}", get(snapshot_class))
+ .route("/snapshot/heat/{heat}", get(snapshot_heat))
+ .route("/snapshot/pilot/{event}/{pilot}", get(snapshot_pilot))
+ .with_state(state)
+}
+
+/// The projection a heat-scope snapshot returns. Selected by `?projection=…`; defaults to
+/// the live race-state.
+#[derive(Debug, Clone, Copy, Default, Deserialize)]
+#[serde(rename_all = "snake_case")]
+enum HeatProjection {
+ /// The live race-state for the heat (default).
+ #[default]
+ Live,
+ /// The heat's per-pilot [`LapList`].
+ Laps,
+ /// The heat's scored [`HeatResult`].
+ Result,
+}
+
+/// Query parameters for the heat-scope endpoint.
+#[derive(Debug, Default, Deserialize)]
+struct HeatQuery {
+ #[serde(default)]
+ projection: HeatProjection,
+}
+
+/// `GET /snapshot/event/{event}` — the whole event's live race-state (§4 event scope).
+async fn snapshot_event(
+ State(state): State,
+ Path(_event): Path,
+) -> Result, ProtocolError> {
+ let (events, cursor) = state.read()?;
+ Ok(Json(Snapshot {
+ cursor,
+ body: ProjectionBody::LiveRaceState(live_state(&events)),
+ }))
+}
+
+/// `GET /snapshot/class/{event}/{class}` — a class's live race-state (§4 class scope).
+///
+/// Class-level *filtering* of the log is deferred (the log carries no class tag yet — see
+/// the module docs); this serves the whole-event live state under the class address so the
+/// scope is reachable now and tightens later without an addressing change.
+async fn snapshot_class(
+ State(state): State,
+ Path((_event, _class)): Path<(EventId, ClassId)>,
+) -> Result, ProtocolError> {
+ let (events, cursor) = state.read()?;
+ Ok(Json(Snapshot {
+ cursor,
+ body: ProjectionBody::LiveRaceState(live_state(&events)),
+ }))
+}
+
+/// `GET /snapshot/heat/{heat}` — the tightest scope (§4 heat scope).
+///
+/// `?projection=live` (default) returns the heat's [`LiveRaceState`]; `?projection=laps`
+/// its [`LapList`]; `?projection=result` its scored [`HeatResult`]. The log is filtered to
+/// the heat's window so the body is heat-local.
+async fn snapshot_heat(
+ State(state): State,
+ Path(heat): Path,
+ Query(query): Query,
+) -> Result, ProtocolError> {
+ let (events, cursor) = state.read()?;
+
+ // The heat must exist in the log (a `HeatScheduled` for this id), else UnknownScope.
+ let scheduled = events
+ .iter()
+ .any(|e| matches!(e, Event::HeatScheduled { heat: h, .. } if *h == heat));
+ if !scheduled {
+ return Err(ProtocolError::new(
+ ErrorCode::UnknownScope,
+ format!("no heat scheduled with id {:?}", heat.0),
+ ));
+ }
+
+ let heat_events = heat_window(&events, &heat);
+
+ let body = match query.projection {
+ HeatProjection::Live => {
+ // Fold the heat's window into live state (it is the only heat present, so it
+ // is the current one).
+ ProjectionBody::LiveRaceState(live_state(&heat_events))
+ }
+ HeatProjection::Laps => ProjectionBody::LapList(lap_list_marshaled(
+ heat_events.iter().enumerate().map(|(i, e)| (i as u64, e)),
+ )),
+ HeatProjection::Result => {
+ // The win condition is heat / format config not carried in the raw log; the
+ // snapshot scores the heat's passes under a neutral best-lap qualifying rule
+ // so the result body is populated. The authoritative per-heat win condition is
+ // applied by the engine when scoring is driven (#45); refining the served
+ // result to the configured condition is part of that work.
+ let race_start = heat_events
+ .iter()
+ .filter_map(first_pass_at)
+ .min()
+ .unwrap_or(SourceTime::from_micros(0));
+ ProjectionBody::HeatResult(score_events(
+ &heat_events,
+ WinCondition::BestLap,
+ race_start,
+ ))
+ }
+ };
+
+ Ok(Json(Snapshot { cursor, body }))
+}
+
+/// `GET /snapshot/pilot/{event}/{pilot}` — a pilot's laps across the event (§4 pilot scope).
+///
+/// Filters the lap projection to the competitor whose ref equals the `pilot` id (the
+/// registration binding mapping a `PilotId` to source competitors is out of scope here —
+/// see the module docs).
+async fn snapshot_pilot(
+ State(state): State,
+ Path((_event, pilot)): Path<(EventId, PilotId)>,
+) -> Result, ProtocolError> {
+ let (events, cursor) = state.read()?;
+
+ let full = lap_list_marshaled(events.iter().enumerate().map(|(i, e)| (i as u64, e)));
+ let pilot_ref = CompetitorRef(pilot.0.clone());
+ let competitors: Vec<_> = full
+ .competitors
+ .into_iter()
+ .filter(|c| c.competitor.competitor == pilot_ref)
+ .collect();
+
+ if competitors.is_empty() {
+ return Err(ProtocolError::new(
+ ErrorCode::UnknownScope,
+ format!("no laps for pilot {:?} in this event", pilot.0),
+ ));
+ }
+
+ Ok(Json(Snapshot {
+ cursor,
+ body: ProjectionBody::LapList(LapList { competitors }),
+ }))
+}
+
+/// The `at` of an event if it is a lap-gate pass, for deriving a heat's race start.
+fn first_pass_at(event: &Event) -> Option {
+ match event {
+ Event::Pass(p) if p.gate.is_lap_gate() => Some(p.at),
+ _ => None,
+ }
+}
+
+/// Filter the log to a single heat's window: that heat's scheduling / state-change events,
+/// plus all passes and marshaling adjudications that fall *while the heat is the active
+/// one*.
+///
+/// With one heat per log in the common case this is the whole log; with several heats it
+/// scopes passes to the span between this heat's first scheduling/transition and the next
+/// heat's. Passes carry no heat id (they are raw observations), so attribution is by
+/// position in the log relative to heat-loop events — the same ordering the engine uses to
+/// decide which heat consumes a pass (race-engine.html §2).
+fn heat_window(events: &[Event], heat: &HeatId) -> Vec {
+ let mut window = Vec::new();
+ // `active` tracks whether the cursor is currently inside this heat's span: it opens on
+ // a heat-loop event for `heat` and closes on a heat-loop event for a *different* heat.
+ let mut active = false;
+ for event in events {
+ match event {
+ Event::HeatScheduled { heat: h, .. } | Event::HeatStateChanged { heat: h, .. } => {
+ active = h == heat;
+ if active {
+ window.push(event.clone());
+ }
+ }
+ // Passes and adjudications belong to whichever heat is currently active.
+ _ if active => window.push(event.clone()),
+ _ => {}
+ }
+ }
+ window
+}
+
+/// Render a [`ProtocolError`] as an HTTP error response (protocol.html §9.8): the JSON
+/// error body under the status its [`ErrorCode`] maps to. The single shared error shape is
+/// returned uniformly across every snapshot path.
+impl IntoResponse for ProtocolError {
+ fn into_response(self) -> Response {
+ let status = match self.code {
+ ErrorCode::Unauthorized => StatusCode::UNAUTHORIZED,
+ ErrorCode::UnknownScope => StatusCode::NOT_FOUND,
+ ErrorCode::StaleCursor => StatusCode::GONE,
+ ErrorCode::VersionMismatch => StatusCode::UPGRADE_REQUIRED,
+ ErrorCode::BadRequest => StatusCode::BAD_REQUEST,
+ ErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR,
+ };
+ (status, Json(self)).into_response()
+ }
+}
+
+// `EventId` / `ClassId` / `PilotId` / `HeatId` are transparent string newtypes, so axum's
+// `Path` extractor deserializes a single path segment straight into them via serde.
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use axum::body::Body;
+ use axum::http::Request;
+ use gridfpv_events::{AdapterId, GateIndex, HeatTransition, Pass};
+ use gridfpv_projection::CompetitorKey;
+ use gridfpv_storage::InMemoryLog;
+ use http_body_util::BodyExt;
+ use tower::ServiceExt;
+
+ use crate::snapshot::HeatPhase;
+
+ fn pass(competitor: &str, at: i64, seq: u64) -> Event {
+ Event::Pass(Pass {
+ adapter: AdapterId("vd".into()),
+ competitor: CompetitorRef(competitor.into()),
+ at: SourceTime::from_micros(at),
+ sequence: Some(seq),
+ gate: GateIndex::LAP,
+ signal: None,
+ })
+ }
+
+ /// A recorded heat log: q-1 scheduled, run through to Scored, with laps for A and B.
+ fn recorded_heat() -> Vec {
+ vec![
+ Event::HeatScheduled {
+ heat: HeatId("q-1".into()),
+ lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())],
+ },
+ Event::HeatStateChanged {
+ heat: HeatId("q-1".into()),
+ transition: HeatTransition::Staged,
+ },
+ Event::HeatStateChanged {
+ heat: HeatId("q-1".into()),
+ transition: HeatTransition::Armed,
+ },
+ Event::HeatStateChanged {
+ heat: HeatId("q-1".into()),
+ transition: HeatTransition::Running,
+ },
+ pass("A", 1_000_000, 1),
+ pass("B", 1_500_000, 1),
+ pass("A", 4_000_000, 2), // A lap 1 = 3.0s
+ pass("B", 5_500_000, 2), // B lap 1 = 4.0s
+ pass("A", 6_500_000, 3), // A lap 2 = 2.5s
+ Event::HeatStateChanged {
+ heat: HeatId("q-1".into()),
+ transition: HeatTransition::Finished,
+ },
+ Event::HeatStateChanged {
+ heat: HeatId("q-1".into()),
+ transition: HeatTransition::Scored,
+ },
+ ]
+ }
+
+ fn state_with(events: Vec) -> (AppState, u64) {
+ let mut log = InMemoryLog::default();
+ for e in &events {
+ EventLog::append(&mut log, e.clone(), None).unwrap();
+ }
+ let len = events.len() as u64;
+ (AppState::new(log), len)
+ }
+
+ async fn get_snapshot(state: AppState, uri: &str) -> (StatusCode, Option) {
+ let response = router(state)
+ .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
+ .await
+ .unwrap();
+ let status = response.status();
+ let bytes = response.into_body().collect().await.unwrap().to_bytes();
+ let snap = serde_json::from_slice::(&bytes).ok();
+ (status, snap)
+ }
+
+ #[tokio::test]
+ async fn event_scope_returns_live_state_and_cursor() {
+ let (state, len) = state_with(recorded_heat());
+ let (status, snap) = get_snapshot(state, "/snapshot/event/spring-cup").await;
+ assert_eq!(status, StatusCode::OK);
+ let snap = snap.unwrap();
+ // The cursor is the log length at read time — the resume point.
+ assert_eq!(snap.cursor, Cursor::new(len));
+ match snap.body {
+ ProjectionBody::LiveRaceState(ls) => {
+ assert_eq!(ls.current_heat, Some(HeatId("q-1".into())));
+ assert_eq!(ls.phase, HeatPhase::Scored);
+ assert_eq!(
+ ls.active_pilots,
+ vec![CompetitorRef("A".into()), CompetitorRef("B".into())]
+ );
+ // A leads (2 laps vs 1).
+ assert_eq!(ls.running_order.first(), Some(&CompetitorRef("A".into())));
+ }
+ other => panic!("expected live state, got {other:?}"),
+ }
+ }
+
+ #[tokio::test]
+ async fn heat_scope_default_is_live_state() {
+ let (state, len) = state_with(recorded_heat());
+ let (status, snap) = get_snapshot(state, "/snapshot/heat/q-1").await;
+ assert_eq!(status, StatusCode::OK);
+ let snap = snap.unwrap();
+ assert_eq!(snap.cursor, Cursor::new(len));
+ assert!(matches!(snap.body, ProjectionBody::LiveRaceState(_)));
+ }
+
+ #[tokio::test]
+ async fn heat_scope_laps_projection_returns_lap_list() {
+ let (state, _) = state_with(recorded_heat());
+ let (status, snap) = get_snapshot(state, "/snapshot/heat/q-1?projection=laps").await;
+ assert_eq!(status, StatusCode::OK);
+ match snap.unwrap().body {
+ ProjectionBody::LapList(laps) => {
+ let a = laps
+ .competitor(&CompetitorKey {
+ adapter: AdapterId("vd".into()),
+ competitor: CompetitorRef("A".into()),
+ })
+ .unwrap();
+ assert_eq!(a.lap_count(), 2);
+ }
+ other => panic!("expected lap list, got {other:?}"),
+ }
+ }
+
+ #[tokio::test]
+ async fn heat_scope_result_projection_returns_heat_result() {
+ let (state, _) = state_with(recorded_heat());
+ let (status, snap) = get_snapshot(state, "/snapshot/heat/q-1?projection=result").await;
+ assert_eq!(status, StatusCode::OK);
+ match snap.unwrap().body {
+ ProjectionBody::HeatResult(result) => {
+ // Both A and B placed.
+ assert_eq!(result.places.len(), 2);
+ }
+ other => panic!("expected heat result, got {other:?}"),
+ }
+ }
+
+ #[tokio::test]
+ async fn unknown_heat_is_not_found() {
+ let (state, _) = state_with(recorded_heat());
+ let response = router(state)
+ .oneshot(
+ Request::builder()
+ .uri("/snapshot/heat/does-not-exist")
+ .body(Body::empty())
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+ let bytes = response.into_body().collect().await.unwrap().to_bytes();
+ let err: ProtocolError = serde_json::from_slice(&bytes).unwrap();
+ assert_eq!(err.code, ErrorCode::UnknownScope);
+ }
+
+ #[tokio::test]
+ async fn pilot_scope_filters_to_the_pilot_laps() {
+ let (state, len) = state_with(recorded_heat());
+ let (status, snap) = get_snapshot(state, "/snapshot/pilot/spring-cup/A").await;
+ assert_eq!(status, StatusCode::OK);
+ let snap = snap.unwrap();
+ assert_eq!(snap.cursor, Cursor::new(len));
+ match snap.body {
+ ProjectionBody::LapList(laps) => {
+ // Only pilot A's laps, not B's.
+ assert_eq!(laps.competitors.len(), 1);
+ assert_eq!(
+ laps.competitors[0].competitor.competitor,
+ CompetitorRef("A".into())
+ );
+ assert_eq!(laps.competitors[0].lap_count(), 2);
+ }
+ other => panic!("expected lap list, got {other:?}"),
+ }
+ }
+
+ #[tokio::test]
+ async fn unknown_pilot_is_not_found() {
+ let (state, _) = state_with(recorded_heat());
+ let response = router(state)
+ .oneshot(
+ Request::builder()
+ .uri("/snapshot/pilot/spring-cup/nobody")
+ .body(Body::empty())
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+ }
+
+ #[tokio::test]
+ async fn class_scope_is_reachable() {
+ let (state, len) = state_with(recorded_heat());
+ let (status, snap) = get_snapshot(state, "/snapshot/class/spring-cup/open").await;
+ assert_eq!(status, StatusCode::OK);
+ let snap = snap.unwrap();
+ assert_eq!(snap.cursor, Cursor::new(len));
+ assert!(matches!(snap.body, ProjectionBody::LiveRaceState(_)));
+ }
+
+ #[tokio::test]
+ async fn empty_log_event_scope_is_idle_with_zero_cursor() {
+ let (state, _) = state_with(vec![]);
+ let (status, snap) = get_snapshot(state, "/snapshot/event/spring-cup").await;
+ assert_eq!(status, StatusCode::OK);
+ let snap = snap.unwrap();
+ assert_eq!(snap.cursor, Cursor::new(0));
+ match snap.body {
+ ProjectionBody::LiveRaceState(ls) => assert_eq!(ls.current_heat, None),
+ other => panic!("expected idle live state, got {other:?}"),
+ }
+ }
+
+ #[tokio::test]
+ async fn two_heats_scope_to_their_own_windows() {
+ // Two heats in one log; the heat scope must filter to its own passes.
+ let events = vec![
+ Event::HeatScheduled {
+ heat: HeatId("q-1".into()),
+ lineup: vec![CompetitorRef("A".into())],
+ },
+ Event::HeatStateChanged {
+ heat: HeatId("q-1".into()),
+ transition: HeatTransition::Running,
+ },
+ pass("A", 1_000_000, 1),
+ pass("A", 4_000_000, 2), // q-1: A one lap
+ Event::HeatScheduled {
+ heat: HeatId("q-2".into()),
+ lineup: vec![CompetitorRef("B".into())],
+ },
+ Event::HeatStateChanged {
+ heat: HeatId("q-2".into()),
+ transition: HeatTransition::Running,
+ },
+ pass("B", 10_000_000, 1),
+ pass("B", 13_000_000, 2),
+ pass("B", 15_000_000, 3), // q-2: B two laps
+ ];
+ let (state, _) = state_with(events);
+
+ let (_, snap) = get_snapshot(state.clone(), "/snapshot/heat/q-1?projection=laps").await;
+ match snap.unwrap().body {
+ ProjectionBody::LapList(laps) => {
+ // Only A appears in q-1's window.
+ assert_eq!(laps.competitors.len(), 1);
+ assert_eq!(
+ laps.competitors[0].competitor.competitor,
+ CompetitorRef("A".into())
+ );
+ }
+ other => panic!("expected lap list, got {other:?}"),
+ }
+
+ let (_, snap) = get_snapshot(state, "/snapshot/heat/q-2?projection=laps").await;
+ match snap.unwrap().body {
+ ProjectionBody::LapList(laps) => {
+ assert_eq!(laps.competitors.len(), 1);
+ assert_eq!(
+ laps.competitors[0].competitor.competitor,
+ CompetitorRef("B".into())
+ );
+ assert_eq!(laps.competitors[0].lap_count(), 2);
+ }
+ other => panic!("expected lap list, got {other:?}"),
+ }
+ }
+}
diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs
index 59d9ed7..bc1220f 100644
--- a/crates/server/src/lib.rs
+++ b/crates/server/src/lib.rs
@@ -3,14 +3,15 @@
//! by the Cloud). The wire types are defined here once and generated to TypeScript
//! (ts-rs), so clients never hand-write a wire type. See docs/protocol.html.
//!
-//! # What this crate is (and is not), as of #40
+//! # What this crate is, as of #41 / #42
//!
-//! This is the **wire-type surface only** — the Rust types that *are* the protocol
-//! (protocol.html §6: "the contract defined once, in Rust"). There is **no transport
-//! here yet**: the axum HTTP/WS server (snapshot endpoint #42, change stream #43,
-//! auth #44, control #45) is built on top of these types in later issues. Defining
-//! the types first means every one of those issues — and the frontend protocol
-//! client (#49) — builds against a single generated contract that already exists.
+//! The wire-type surface — the Rust types that *are* the protocol (protocol.html §6:
+//! "the contract defined once, in Rust") — plus the **read transport** over them: the
+//! live race-state projection ([`live_state`], #41) and the axum snapshot endpoints
+//! ([`app`], #42). The change stream (#43), auth (#44), and control endpoints (#45)
+//! build on the same [`app::AppState`] and [`app::router`]. Defining the types first
+//! means every one of those issues — and the frontend protocol client (#49) — builds
+//! against a single generated contract that already exists.
//!
//! Every public wire type derives [`serde`] (its JSON encoding *is* the wire format)
//! and [`ts_rs::TS`] with `#[ts(export, export_to = "bindings/")]`, exactly mirroring
@@ -42,17 +43,20 @@
//! (a single appended lap, a heat-state transition, …) are pinned when the change
//! stream lands (#43). The *structure* — per-projection, delta-vs-fresh — is fixed
//! here, which is what later issues need.
-//! - **Scope addressing grammar.** [`Scope`](scope::Scope) enumerates the four
-//! addressable resources (event/class/heat/pilot, §4); the full filter/grammar and
-//! URL shapes (§9.6) are refined when the snapshot endpoint lands (#42).
-//! - **`LiveRaceState`** ([`snapshot::LiveRaceState`]) is a real-but-minimal
-//! placeholder; #41 fills in live lap/split progress, running order, and on-deck.
+//! - **Scope addressing grammar.** [`app::router`] pins a concrete REST addressing over
+//! the four resources (event/class/heat/pilot, §4); the richer filter grammar (§9.6),
+//! and the log-level *class* filter (the log carries no class tag yet), are refined in
+//! the doc-reconciliation pass and when the schedule model grows class tags.
+//! - **Split-level live progress.** [`live_state::LiveRaceState`] carries lap-count and
+//! last-lap progress; per-gate split progress is a later refinement.
//! - **Auth tokens / sessions.** The credential format (§9.4) is a #44 concern; no
//! token types live here yet.
#![forbid(unsafe_code)]
+pub mod app;
pub mod control;
pub mod error;
+pub mod live_state;
pub mod scope;
pub mod snapshot;
pub mod stream;
diff --git a/crates/server/src/live_state.rs b/crates/server/src/live_state.rs
new file mode 100644
index 0000000..8134720
--- /dev/null
+++ b/crates/server/src/live_state.rs
@@ -0,0 +1,439 @@
+//! The live race-state projection (protocol.html §1) — issue #41.
+//!
+//! This is the latency-sensitive core every overlay and spectator watches: the heat
+//! currently on the timer and its loop [`HeatPhase`], the active pilots in that heat,
+//! each pilot's live lap progress, the running order, and the on-deck (next scheduled)
+//! heat. It is a **pure fold of the event log** ([`live_state`]): given the same events
+//! it always produces the same [`LiveRaceState`], so a recorded session replays
+//! identically and the snapshot is recomputable (protocol.html §2–§3).
+//!
+//! # What "current heat" means here
+//!
+//! The event log carries [`Event::HeatScheduled`] and
+//! [`Event::HeatStateChanged`](gridfpv_events::Event::HeatStateChanged) for every heat
+//! (race-engine.html §2). The **current heat** is the most-recently-active one: the heat
+//! whose latest state-changing event appears last in the log and that is not yet
+//! `Scored`/`Advanced` past the timer. We resolve it by scanning the log in order and
+//! tracking the last heat to receive a `HeatScheduled` or `HeatStateChanged`. A heat
+//! that has reached the terminal `Scored` phase is still reported as the current heat
+//! until a *newer* heat takes the timer (a freshly-scheduled or transitioned heat),
+//! which mirrors what an overlay shows between heats ("last heat, now scored").
+//!
+//! # On-deck
+//!
+//! The **on-deck** heat is the next `Scheduled` heat that is not the current one and has
+//! not yet run — the heat the RD will stage next. With no schedule metadata in the raw
+//! log (seat/frequency assignment lands later, #36) this is simply the earliest-scheduled
+//! heat still sitting in `Scheduled` that isn't already on the timer.
+//!
+//! # Live progress and running order
+//!
+//! Per-pilot live progress reuses the existing lap projection
+//! ([`gridfpv_projection::lap_list_marshaled`]) filtered to the current heat's lineup, so
+//! marshaling adjudications already fold in. The **running order** ranks the active pilots
+//! by laps completed (descending) then by the completion time of their last lap (earliest
+//! first) — the same "most laps, then who banked the last lap first" rule the scorer uses
+//! mid-heat (race-engine.html §7.4), but derived without a win condition (which is heat /
+//! format config not present in the raw log). It is therefore a *provisional* live order,
+//! not the scored result; the authoritative scored ranking is the
+//! [`HeatResult`](gridfpv_engine::scoring::HeatResult) projection.
+
+use std::collections::BTreeMap;
+
+use gridfpv_engine::heat::{HeatState, heat_state};
+use gridfpv_events::{CompetitorRef, Event, HeatId};
+use gridfpv_projection::{CompetitorKey, lap_list_marshaled};
+use serde::{Deserialize, Serialize};
+use ts_rs::TS;
+
+use crate::snapshot::HeatPhase;
+
+/// The current **live race-state** projection (protocol.html §1) — the latency-sensitive
+/// core every overlay and spectator watches.
+///
+/// Fleshed out in #41 from the #40 placeholder: it carries the current heat and its
+/// [`HeatPhase`], the active pilots in that heat, each pilot's live
+/// [`PilotProgress`], the running order, and the on-deck heat. Fields are additive over
+/// the placeholder (§7), so the snapshot body and change envelope did not reshape.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub struct LiveRaceState {
+ /// The heat currently on the timer, if any (`None` before any heat is scheduled).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[ts(optional)]
+ pub current_heat: Option,
+ /// The current heat's loop phase (protocol.html §1, race-engine.html §2). When
+ /// `current_heat` is `None` this is [`HeatPhase::Scheduled`] (the idle default).
+ pub phase: HeatPhase,
+ /// The active pilots in the current heat — its lineup, in lineup (seeding) order.
+ /// Empty when there is no current heat.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub active_pilots: Vec,
+ /// Per-pilot live lap progress for the current heat, one entry per active pilot,
+ /// ordered like [`active_pilots`](Self::active_pilots).
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub progress: Vec,
+ /// The provisional running order of the current heat: the active pilots ranked by
+ /// live standing (most laps, then who banked their last lap earliest). Best first.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub running_order: Vec,
+ /// The next heat to run after the current one (the earliest still-`Scheduled` heat
+ /// that is not on the timer), if one is known.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[ts(optional)]
+ pub on_deck: Option,
+}
+
+impl Default for LiveRaceState {
+ /// The idle live state: no current heat, `Scheduled` phase, nothing active.
+ fn default() -> Self {
+ Self {
+ current_heat: None,
+ phase: HeatPhase::Scheduled,
+ active_pilots: Vec::new(),
+ progress: Vec::new(),
+ running_order: Vec::new(),
+ on_deck: None,
+ }
+ }
+}
+
+/// One active pilot's live progress in the current heat (protocol.html §1).
+///
+/// Derived from the heat's lap projection: the number of laps completed so far and the
+/// duration of the most recent completed lap (the live "last lap" an overlay shows).
+/// Splits are a later refinement; the lap-count + last-lap pair is the live core.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub struct PilotProgress {
+ /// The source-local competitor this progress is for (a member of the lineup).
+ pub competitor: CompetitorRef,
+ /// Completed laps so far in the heat.
+ pub laps_completed: u32,
+ /// Duration (µs, source clock) of the most recently completed lap, or `None` before
+ /// the pilot has completed a lap.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[ts(optional)]
+ pub last_lap_micros: Option,
+}
+
+/// Fold the event log into the [`LiveRaceState`] (protocol.html §1) — issue #41.
+///
+/// Pure and order-preserving: scans `events` once to find the current heat (the
+/// most-recently-active one, see the module docs), folds its [`HeatState`] into a
+/// [`HeatPhase`], reads its lineup, derives each active pilot's [`PilotProgress`] from the
+/// (marshaling-aware) lap projection, ranks them into a running order, and finds the
+/// on-deck heat. Replaying the same log twice yields the same state.
+pub fn live_state(events: &[Event]) -> LiveRaceState {
+ let Some(current_heat) = current_heat(events) else {
+ return LiveRaceState::default();
+ };
+
+ let phase = heat_state(events, ¤t_heat)
+ .map(phase_of)
+ .unwrap_or(HeatPhase::Scheduled);
+
+ let active_pilots = lineup_of(events, ¤t_heat);
+
+ // The lap projection is keyed on (adapter, competitor); the lineup carries only the
+ // competitor handle. Fold the whole log once (marshaling-aware) and index laps by
+ // competitor ref, summing across adapters for a competitor seen on more than one.
+ let laps = lap_list_marshaled(events.iter().enumerate().map(|(i, e)| (i as u64, e)));
+ let mut by_ref: BTreeMap<&CompetitorRef, (u32, Option)> = BTreeMap::new();
+ for cl in &laps.competitors {
+ let CompetitorKey { competitor, .. } = &cl.competitor;
+ let entry = by_ref.entry(competitor).or_insert((0, None));
+ entry.0 += cl.lap_count() as u32;
+ entry.1 = cl.laps.last().map(|l| l.duration_micros).or(entry.1);
+ }
+
+ let progress: Vec = active_pilots
+ .iter()
+ .map(|competitor| {
+ let (laps_completed, last_lap_micros) =
+ by_ref.get(competitor).copied().unwrap_or((0, None));
+ PilotProgress {
+ competitor: competitor.clone(),
+ laps_completed,
+ last_lap_micros,
+ }
+ })
+ .collect();
+
+ let running_order = running_order(&progress);
+
+ LiveRaceState {
+ current_heat: Some(current_heat.clone()),
+ phase,
+ active_pilots,
+ progress,
+ running_order,
+ on_deck: on_deck(events, ¤t_heat),
+ }
+}
+
+/// Map a folded [`HeatState`] to the projected [`HeatPhase`] the live view reports.
+fn phase_of(state: HeatState) -> HeatPhase {
+ match state {
+ HeatState::Scheduled => HeatPhase::Scheduled,
+ HeatState::Staged => HeatPhase::Staged,
+ HeatState::Armed => HeatPhase::Armed,
+ HeatState::Running => HeatPhase::Running,
+ HeatState::Finished => HeatPhase::Landed,
+ HeatState::Scored => HeatPhase::Scored,
+ }
+}
+
+/// The current heat: the heat whose latest `HeatScheduled` / `HeatStateChanged` event
+/// appears last in the log. `None` if no heat was ever scheduled.
+fn current_heat(events: &[Event]) -> Option {
+ let mut current: Option = None;
+ for event in events {
+ match event {
+ Event::HeatScheduled { heat, .. } | Event::HeatStateChanged { heat, .. } => {
+ current = Some(heat.clone());
+ }
+ _ => {}
+ }
+ }
+ current
+}
+
+/// The lineup of a heat: the competitors from its most recent `HeatScheduled`.
+fn lineup_of(events: &[Event], heat: &HeatId) -> Vec {
+ let mut lineup = Vec::new();
+ for event in events {
+ if let Event::HeatScheduled { heat: h, lineup: l } = event {
+ if h == heat {
+ lineup = l.clone();
+ }
+ }
+ }
+ lineup
+}
+
+/// The on-deck heat: the earliest still-`Scheduled` heat that is not the current one.
+///
+/// "Still scheduled" means its folded [`HeatState`] is `Scheduled` (it has been created
+/// but not staged). Heats are considered in the order they were first scheduled in the
+/// log, so the on-deck heat is the next one queued behind the current heat.
+fn on_deck(events: &[Event], current: &HeatId) -> Option {
+ let mut seen: Vec = Vec::new();
+ for event in events {
+ if let Event::HeatScheduled { heat, .. } = event {
+ if !seen.contains(heat) {
+ seen.push(heat.clone());
+ }
+ }
+ }
+ seen.into_iter()
+ .find(|heat| heat != current && heat_state(events, heat) == Some(HeatState::Scheduled))
+}
+
+/// Rank active pilots into the provisional running order: most laps first, ties broken
+/// by the shorter last-lap time (a proxy for who is pacing ahead), then by competitor
+/// ref for a total, deterministic order.
+fn running_order(progress: &[PilotProgress]) -> Vec {
+ let mut order: Vec<&PilotProgress> = progress.iter().collect();
+ order.sort_by(|a, b| {
+ b.laps_completed
+ .cmp(&a.laps_completed)
+ .then_with(|| match (a.last_lap_micros, b.last_lap_micros) {
+ (Some(x), Some(y)) => x.cmp(&y),
+ (Some(_), None) => std::cmp::Ordering::Less,
+ (None, Some(_)) => std::cmp::Ordering::Greater,
+ (None, None) => std::cmp::Ordering::Equal,
+ })
+ .then_with(|| a.competitor.cmp(&b.competitor))
+ });
+ order.into_iter().map(|p| p.competitor.clone()).collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use gridfpv_events::{AdapterId, GateIndex, HeatTransition, Pass, SourceTime};
+
+ fn heat() -> HeatId {
+ HeatId("q-1".into())
+ }
+
+ fn scheduled(id: &str, lineup: &[&str]) -> Event {
+ Event::HeatScheduled {
+ heat: HeatId(id.into()),
+ lineup: lineup.iter().map(|c| CompetitorRef((*c).into())).collect(),
+ }
+ }
+
+ fn changed(id: &str, transition: HeatTransition) -> Event {
+ Event::HeatStateChanged {
+ heat: HeatId(id.into()),
+ transition,
+ }
+ }
+
+ fn pass(competitor: &str, at: i64, seq: u64) -> Event {
+ Event::Pass(Pass {
+ adapter: AdapterId("vd".into()),
+ competitor: CompetitorRef(competitor.into()),
+ at: SourceTime::from_micros(at),
+ sequence: Some(seq),
+ gate: GateIndex::LAP,
+ signal: None,
+ })
+ }
+
+ #[test]
+ fn empty_log_is_idle_default() {
+ assert_eq!(live_state(&[]), LiveRaceState::default());
+ let s = live_state(&[]);
+ assert_eq!(s.current_heat, None);
+ assert_eq!(s.phase, HeatPhase::Scheduled);
+ assert!(s.active_pilots.is_empty());
+ }
+
+ #[test]
+ fn scheduled_heat_reports_lineup_and_scheduled_phase() {
+ let events = vec![scheduled("q-1", &["A", "B", "C"])];
+ let s = live_state(&events);
+ assert_eq!(s.current_heat, Some(heat()));
+ assert_eq!(s.phase, HeatPhase::Scheduled);
+ assert_eq!(
+ s.active_pilots,
+ vec![
+ CompetitorRef("A".into()),
+ CompetitorRef("B".into()),
+ CompetitorRef("C".into())
+ ]
+ );
+ // No laps yet: progress entries exist but are zeroed.
+ assert_eq!(s.progress.len(), 3);
+ assert!(s.progress.iter().all(|p| p.laps_completed == 0));
+ }
+
+ #[test]
+ fn phase_tracks_the_heat_loop_through_scored() {
+ // Scheduled → Staged → Armed → Running → Finished(Landed) → Scored.
+ let steps = [
+ (HeatTransition::Staged, HeatPhase::Staged),
+ (HeatTransition::Armed, HeatPhase::Armed),
+ (HeatTransition::Running, HeatPhase::Running),
+ (HeatTransition::Finished, HeatPhase::Landed),
+ (HeatTransition::Scored, HeatPhase::Scored),
+ ];
+ let mut events = vec![scheduled("q-1", &["A", "B"])];
+ assert_eq!(live_state(&events).phase, HeatPhase::Scheduled);
+ for (transition, expected) in steps {
+ events.push(changed("q-1", transition));
+ assert_eq!(live_state(&events).phase, expected, "after {transition:?}");
+ }
+ }
+
+ #[test]
+ fn live_progress_counts_laps_and_last_lap_per_pilot() {
+ // A: 3 passes ⇒ 2 laps (last lap 2.5s). B: 2 passes ⇒ 1 lap (3.0s).
+ let events = vec![
+ scheduled("q-1", &["A", "B"]),
+ changed("q-1", HeatTransition::Staged),
+ changed("q-1", HeatTransition::Armed),
+ changed("q-1", HeatTransition::Running),
+ pass("A", 1_000_000, 1),
+ pass("B", 1_500_000, 1),
+ pass("A", 4_000_000, 2),
+ pass("B", 4_500_000, 2),
+ pass("A", 6_500_000, 3),
+ ];
+ let s = live_state(&events);
+ assert_eq!(s.phase, HeatPhase::Running);
+
+ let a = s
+ .progress
+ .iter()
+ .find(|p| p.competitor == CompetitorRef("A".into()))
+ .unwrap();
+ assert_eq!(a.laps_completed, 2);
+ assert_eq!(a.last_lap_micros, Some(2_500_000));
+
+ let b = s
+ .progress
+ .iter()
+ .find(|p| p.competitor == CompetitorRef("B".into()))
+ .unwrap();
+ assert_eq!(b.laps_completed, 1);
+ assert_eq!(b.last_lap_micros, Some(3_000_000));
+
+ // Running order: A (2 laps) leads B (1 lap).
+ assert_eq!(
+ s.running_order,
+ vec![CompetitorRef("A".into()), CompetitorRef("B".into())]
+ );
+ }
+
+ #[test]
+ fn running_order_breaks_lap_ties_by_last_lap_time() {
+ // Both completed 1 lap; B's lap (2.0s) is faster than A's (3.0s) ⇒ B leads.
+ let events = vec![
+ scheduled("q-1", &["A", "B"]),
+ changed("q-1", HeatTransition::Running),
+ pass("A", 1_000_000, 1),
+ pass("B", 1_000_000, 1),
+ pass("A", 4_000_000, 2), // A lap = 3.0s
+ pass("B", 3_000_000, 2), // B lap = 2.0s
+ ];
+ let s = live_state(&events);
+ assert_eq!(
+ s.running_order,
+ vec![CompetitorRef("B".into()), CompetitorRef("A".into())]
+ );
+ }
+
+ #[test]
+ fn current_heat_follows_the_most_recently_active_heat() {
+ // q-1 runs and scores; q-2 is then scheduled and becomes current.
+ let events = vec![
+ scheduled("q-1", &["A", "B"]),
+ changed("q-1", HeatTransition::Staged),
+ changed("q-1", HeatTransition::Armed),
+ changed("q-1", HeatTransition::Running),
+ changed("q-1", HeatTransition::Finished),
+ changed("q-1", HeatTransition::Scored),
+ scheduled("q-2", &["C", "D"]),
+ ];
+ let s = live_state(&events);
+ assert_eq!(s.current_heat, Some(HeatId("q-2".into())));
+ assert_eq!(s.phase, HeatPhase::Scheduled);
+ assert_eq!(
+ s.active_pilots,
+ vec![CompetitorRef("C".into()), CompetitorRef("D".into())]
+ );
+ }
+
+ #[test]
+ fn on_deck_is_the_next_still_scheduled_heat() {
+ // q-1 is running (current); q-2 and q-3 are scheduled and waiting.
+ let events = vec![
+ scheduled("q-1", &["A", "B"]),
+ scheduled("q-2", &["C", "D"]),
+ scheduled("q-3", &["E", "F"]),
+ changed("q-1", HeatTransition::Staged),
+ changed("q-1", HeatTransition::Armed),
+ changed("q-1", HeatTransition::Running),
+ ];
+ let s = live_state(&events);
+ assert_eq!(s.current_heat, Some(HeatId("q-1".into())));
+ assert_eq!(s.phase, HeatPhase::Running);
+ // q-2 is the next still-scheduled heat behind the current one.
+ assert_eq!(s.on_deck, Some(HeatId("q-2".into())));
+ }
+
+ #[test]
+ fn fold_is_deterministic() {
+ let events = vec![
+ scheduled("q-1", &["A", "B"]),
+ changed("q-1", HeatTransition::Running),
+ pass("A", 1_000_000, 1),
+ pass("A", 4_000_000, 2),
+ ];
+ assert_eq!(live_state(&events), live_state(&events));
+ }
+}
diff --git a/crates/server/src/snapshot.rs b/crates/server/src/snapshot.rs
index 653d937..0354114 100644
--- a/crates/server/src/snapshot.rs
+++ b/crates/server/src/snapshot.rs
@@ -29,32 +29,15 @@
use gridfpv_engine::event::EventOutcome;
use gridfpv_engine::format::RankEntry;
use gridfpv_engine::scoring::HeatResult;
-use gridfpv_events::HeatId;
use gridfpv_projection::LapList;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::stream::Cursor;
-/// The current **live race-state** projection (protocol.html §1) — the latency-sensitive
-/// core every overlay and spectator watches: the current heat and its loop state, the
-/// active pilots' live lap/split progress, the running order, and the on-deck heat.
-///
-/// **#41 placeholder.** This is intentionally minimal-but-real: it carries the current
-/// heat id and a coarse [`HeatPhase`] so the contract, the snapshot body, and the
-/// change envelope are all wired end to end *now*. #41 fleshes it out with per-pilot
-/// live progress, the running order, and the on-deck heat. Keeping the type real (not a
-/// unit stub) means #41 extends fields additively (§7) without reshaping the envelope.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
-#[ts(export, export_to = "bindings/")]
-pub struct LiveRaceState {
- /// The heat currently on the timer, if any (`None` between heats).
- #[serde(default, skip_serializing_if = "Option::is_none")]
- #[ts(optional)]
- pub current_heat: Option,
- /// The current heat's loop phase (protocol.html §1, race-engine.html §2).
- pub phase: HeatPhase,
-}
+// The live race-state projection (#41) lives in [`crate::live_state`]; it is re-exported
+// here so `ProjectionBody::LiveRaceState` keeps naming a snapshot-module type.
+pub use crate::live_state::{LiveRaceState, PilotProgress};
/// The heat-loop phase the live state reports (protocol.html §1: `Scheduled → Staged →
/// Armed → Running → Landed → Scored`).
@@ -163,8 +146,9 @@ mod tests {
fn sample_live() -> ProjectionBody {
ProjectionBody::LiveRaceState(LiveRaceState {
- current_heat: Some(HeatId("q-1".into())),
+ current_heat: Some(gridfpv_events::HeatId("q-1".into())),
phase: HeatPhase::Running,
+ ..Default::default()
})
}
diff --git a/crates/server/src/stream.rs b/crates/server/src/stream.rs
index 66b4086..684fb07 100644
--- a/crates/server/src/stream.rs
+++ b/crates/server/src/stream.rs
@@ -120,6 +120,7 @@ mod tests {
change: Change::FreshValue(ProjectionBody::LiveRaceState(LiveRaceState {
current_heat: Some(HeatId("q-1".into())),
phase: HeatPhase::Running,
+ ..Default::default()
})),
};
let json = serde_json::to_string(&env).unwrap();
From 1a25297294efe11d54ed7e4b2a7e25f6dfb9898f Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 03:59:42 +0000
Subject: [PATCH 005/362] Wire generated bindings into @gridfpv/types;
implement protocol-client (#49)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace the @gridfpv/types placeholders with a real barrel over the ts-rs
bindings, and implement @gridfpv/protocol-client (snapshot + WS subscribe +
cursor resume), typed end to end against the generated wire types.
Types seam:
- packages/types/src/generated.ts now re-exports every bindings/*.ts module
(export type * from '@bindings/'), resolved via a single @bindings/*
path alias moved to tsconfig.base.json so every workspace resolves the chain
identically. ts-rs emits no index.ts, so this barrel is one line per type.
- @gridfpv/types is a pure type seam (all `export type`): it type-checks with
--noEmit instead of emitting a dist/, which also lets the @bindings/*
re-exports live outside the package tree without tripping rootDir. A Rust
contract change now surfaces as a TS compile error in the frontend.
protocol-client:
- connect({ baseUrl, scope, token? }) -> ProtocolClient. Base-URL only (cannot
tell LAN from Cloud). GET the scoped snapshot (carrying its cursor), then open
a WebSocket and subscribe from that cursor.
- Applies ChangeEnvelopes in strict sequence order, idempotent and keyed by
sequence; a gap re-snapshots and re-subscribes from the fresh cursor; a socket
drop reconnects and resumes by last-applied cursor, with a StaleCursor ->
re-snapshot fallback (protocol.html §2/§3).
- Framework-agnostic state API: getState() + onState(cb). Cursor (u64/bigint)
wire coercion handled at the seam. fetch/WebSocket/timers are injectable.
Tests: vitest unit tests drive the client against a mock fetch (snapshot) and a
scripted mock WebSocket — asserting snapshot+subscribe, in-order convergence,
idempotent re-delivery, gap re-snapshot, stale-cursor re-snapshot, and
reconnect-resume. Updated the Leaderboard/App demo wiring off the removed
placeholder type onto real generated types. build/check/test/lint all green.
Part of #49.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
frontend/README.md | 23 +-
frontend/apps/rd-console/src/App.svelte | 36 +-
frontend/package-lock.json | 955 +++++++++++++++++-
frontend/package.json | 1 +
.../components/src/Leaderboard.svelte | 11 +-
.../packages/protocol-client/package.json | 6 +-
.../protocol-client/src/client.test.ts | 322 ++++++
.../packages/protocol-client/src/client.ts | 458 +++++++++
.../packages/protocol-client/src/index.ts | 70 +-
.../packages/protocol-client/tsconfig.json | 5 +-
.../protocol-client/tsconfig.test.json | 10 +
.../packages/protocol-client/vitest.config.ts | 19 +
frontend/packages/types/package.json | 2 +-
frontend/packages/types/src/generated.ts | 91 +-
frontend/packages/types/src/index.ts | 8 +-
frontend/packages/types/tsconfig.json | 18 +-
frontend/tsconfig.base.json | 11 +
17 files changed, 1888 insertions(+), 158 deletions(-)
create mode 100644 frontend/packages/protocol-client/src/client.test.ts
create mode 100644 frontend/packages/protocol-client/src/client.ts
create mode 100644 frontend/packages/protocol-client/tsconfig.test.json
create mode 100644 frontend/packages/protocol-client/vitest.config.ts
diff --git a/frontend/README.md b/frontend/README.md
index 47bc13b..a833b22 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -28,7 +28,7 @@ frontend/
├── eslint.config.js # flat config, TS + Svelte
├── packages/
│ ├── types/ # @gridfpv/types — re-exports the generated bindings/*.ts
-│ ├── protocol-client/ # @gridfpv/protocol-client — thin transport+subscribe layer (STUB, filled by #49)
+│ ├── protocol-client/ # @gridfpv/protocol-client — thin transport+subscribe layer (snapshot + WS, #49)
│ └── components/ # @gridfpv/components — shared Svelte 5 component library (svelte-package)
└── apps/
└── rd-console/ # @gridfpv/rd-console — the RD console surface (minimal shell, filled by #51+)
@@ -48,23 +48,23 @@ into the repo-root `bindings/` directory (one file per type, per `docs/clients.h
generated bindings so every app and package imports protocol types from one place:
```ts
-import type { RaceSnapshot, PilotId } from '@gridfpv/types';
+import type { Snapshot, ChangeEnvelope, Command, LapList } from '@gridfpv/types';
```
How regenerated bindings flow in:
-1. The Rust side regenerates `bindings/*.ts` (e.g. `cargo test` with ts-rs, or `xtask`).
-2. `packages/types/src/index.ts` re-exports from the generated barrel. While `bindings/` has
- no barrel of its own, `packages/types/src/generated.ts` is the adapter that points at it
- (via the `tsconfig` path alias `@bindings/*` → `../../../bindings/*`).
+1. The Rust side regenerates `bindings/*.ts` (e.g. `cargo xtask gen`).
+2. `packages/types/src/index.ts` re-exports from `packages/types/src/generated.ts`, the
+ barrel. ts-rs emits one file per type and no `index.ts` of its own, so `generated.ts`
+ re-exports each `bindings/*.ts` module (`export type * from '@bindings/'`), resolved
+ through the `@bindings/*` tsconfig path alias (`@bindings/* → ../bindings/*`, defined once
+ in `tsconfig.base.json`). Adding a new type is one new `export type *` line; nothing else.
3. Nothing else changes: apps already import from `@gridfpv/types`, so a contract change in
Rust surfaces as a TypeScript compile error in the frontend rather than silent drift.
-**Standalone-build note.** When `bindings/` is absent (e.g. a frontend-only checkout, or CI
-that hasn't run the Rust generation step), `@gridfpv/types` falls back to a small set of
-placeholder types in `src/generated.ts` so the monorepo still builds and type-checks. Once
-real bindings exist, that fallback is replaced by the re-export — see the comments in
-`packages/types/src/generated.ts`.
+`@gridfpv/types` is a pure type seam — every export is `export type`, so it emits no JS and
+consumers resolve `@gridfpv/types` straight to its TypeScript source. It type-checks with
+`tsc --noEmit` rather than producing a `dist/`.
## Commands
@@ -72,6 +72,7 @@ real bindings exist, that fallback is replaced by the re-export — see the comm
npm install # from frontend/ — installs all workspaces
npm run build # build components + rd-console (and any other workspace)
npm run check # svelte-check / tsc across workspaces
+npm run test # vitest across workspaces (protocol-client unit tests)
npm run lint # eslint + prettier --check
npm run format # prettier --write
npm run dev:rd-console # vite dev server for the RD console
diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte
index 411c6fc..a233fc8 100644
--- a/frontend/apps/rd-console/src/App.svelte
+++ b/frontend/apps/rd-console/src/App.svelte
@@ -7,16 +7,36 @@
// results) is built out in #51+.
import { Leaderboard, RaceClock } from '@gridfpv/components';
import { connect } from '@gridfpv/protocol-client';
- import type { RaceSnapshot } from '@gridfpv/types';
+ import type { HeatResult, Scope } from '@gridfpv/types';
- // Placeholder data until the protocol client (#49) streams real snapshots.
- const demo: RaceSnapshot = {
- raceId: 'demo-heat-1',
- pilots: ['ALICE', 'BOB', 'CARMEN']
+ // Placeholder data until the live projection (#51+) streams real results.
+ const demo: HeatResult = {
+ places: [
+ {
+ competitor: { adapter: 'rh-1', competitor: 'ALICE' },
+ position: 1,
+ laps: 3,
+ metric: { BestLapMicros: 41_250_000n }
+ },
+ {
+ competitor: { adapter: 'rh-1', competitor: 'BOB' },
+ position: 2,
+ laps: 3,
+ metric: { BestLapMicros: 42_100_000n }
+ },
+ {
+ competitor: { adapter: 'rh-1', competitor: 'CARMEN' },
+ position: 3,
+ laps: 2,
+ metric: { BestLapMicros: null }
+ }
+ ]
};
- // The client is a stub today (#49 implements it); this just shows the wiring.
- const client = connect({ baseUrl: 'http://localhost:8080' });
+ // The real protocol client (#49): snapshot + WS subscribe, scoped to this heat.
+ // The console reads `client.getState()` / `client.onState(...)` in #51+.
+ const scope: Scope = { Heat: { heat: 'demo-heat-1' } };
+ const client = connect({ baseUrl: 'http://localhost:8080', scope });
@@ -30,7 +50,7 @@
{/each}
diff --git a/frontend/packages/protocol-client/package.json b/frontend/packages/protocol-client/package.json
index 97f2205..2102a81 100644
--- a/frontend/packages/protocol-client/package.json
+++ b/frontend/packages/protocol-client/package.json
@@ -14,12 +14,14 @@
},
"scripts": {
"build": "tsc -p tsconfig.json",
- "check": "tsc -p tsconfig.json --noEmit"
+ "check": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
+ "test": "vitest run"
},
"dependencies": {
"@gridfpv/types": "*"
},
"devDependencies": {
- "typescript": "^5.7.2"
+ "typescript": "^5.7.2",
+ "vitest": "^2.1.8"
}
}
diff --git a/frontend/packages/protocol-client/src/client.test.ts b/frontend/packages/protocol-client/src/client.test.ts
new file mode 100644
index 0000000..bb897af
--- /dev/null
+++ b/frontend/packages/protocol-client/src/client.test.ts
@@ -0,0 +1,322 @@
+import { describe, expect, it } from 'vitest';
+import type {
+ ChangeEnvelope,
+ HeatPhase,
+ ProjectionBody,
+ ProtocolError,
+ Scope
+} from '@gridfpv/types';
+import { connect } from './client.js';
+import type { FetchLike, WebSocketLike } from './client.js';
+
+// ── Test fixtures ────────────────────────────────────────────────────────────
+
+const SCOPE: Scope = { Heat: { heat: 'heat-1' } };
+
+const liveState = (phase: HeatPhase): ProjectionBody => ({
+ LiveRaceState: { current_heat: 'heat-1', phase }
+});
+
+// A fetch mock that serves the given snapshots in order (sticking on the last)
+// and records every requested URL. Each response's `json()` yields a Snapshot
+// whose cursor is rendered as a JSON number — exactly how serde renders the u64
+// `Cursor` on the wire — which the client coerces back to a bigint.
+function mockFetch(snapshots: Array<{ cursor: bigint; body: ProjectionBody }>): {
+ fetch: FetchLike;
+ calls: string[];
+} {
+ const calls: string[] = [];
+ let i = 0;
+ const fetch: FetchLike = async (input) => {
+ calls.push(String(input));
+ const snap = snapshots[Math.min(i, snapshots.length - 1)];
+ i += 1;
+ return {
+ ok: true,
+ status: 200,
+ json: async (): Promise => ({ cursor: Number(snap.cursor), body: snap.body })
+ } as unknown as Response;
+ };
+ return { fetch, calls };
+}
+
+// A scriptable mock WebSocket. Tests drive it: open it, then push frames.
+class MockWebSocket implements WebSocketLike {
+ onopen: ((this: WebSocketLike, ev: unknown) => unknown) | null = null;
+ onclose: ((this: WebSocketLike, ev: unknown) => unknown) | null = null;
+ onerror: ((this: WebSocketLike, ev: unknown) => unknown) | null = null;
+ onmessage: ((this: WebSocketLike, ev: { data: unknown }) => unknown) | null = null;
+
+ readonly url: string;
+ readonly sent: string[] = [];
+ closed = false;
+
+ constructor(url: string) {
+ this.url = url;
+ }
+
+ send(data: string): void {
+ this.sent.push(data);
+ }
+
+ close(): void {
+ this.closed = true;
+ }
+
+ // ── Test drivers ──
+ open(): void {
+ this.onopen?.call(this, {});
+ }
+ emit(frame: unknown): void {
+ // Mirror the wire: serde renders the u64 Cursor as a JSON number, so emit
+ // bigints as numbers (JSON.stringify cannot serialize a bigint directly).
+ const data = JSON.stringify(frame, (_k, v) => (typeof v === 'bigint' ? Number(v) : v));
+ this.onmessage?.call(this, { data });
+ }
+ drop(): void {
+ this.onclose?.call(this, {});
+ }
+}
+
+// Collect every MockWebSocket the client opens, so tests can drive the latest one.
+function mockWsFactory(): { factory: (url: string) => WebSocketLike; sockets: MockWebSocket[] } {
+ const sockets: MockWebSocket[] = [];
+ const factory = (url: string): WebSocketLike => {
+ const ws = new MockWebSocket(url);
+ sockets.push(ws);
+ return ws;
+ };
+ return { factory, sockets };
+}
+
+// A controllable timer: tests fire the queued callback on demand (deterministic
+// reconnect, no real waiting).
+function manualTimer(): {
+ setTimer: (cb: () => void, ms: number) => unknown;
+ clearTimer: (h: unknown) => void;
+ fire: () => void;
+ pending: () => boolean;
+} {
+ let queued: (() => void) | null = null;
+ return {
+ setTimer: (cb) => {
+ queued = cb;
+ return 1;
+ },
+ clearTimer: () => {
+ queued = null;
+ },
+ fire: () => {
+ const cb = queued;
+ queued = null;
+ cb?.();
+ },
+ pending: () => queued !== null
+ };
+}
+
+const envelope = (sequence: bigint, phase: HeatPhase): ChangeEnvelope => ({
+ sequence,
+ projection: 'LiveRaceState',
+ change: { FreshValue: liveState(phase) }
+});
+
+// Let queued microtasks (the async snapshot fetch) settle.
+const flush = async (): Promise => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+};
+
+const phaseOf = (body: ProjectionBody | undefined): HeatPhase | undefined =>
+ body && 'LiveRaceState' in body ? body.LiveRaceState.phase : undefined;
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+
+describe('ProtocolClient', () => {
+ it('fetches the scoped snapshot, then subscribes from its cursor', async () => {
+ const { fetch, calls } = mockFetch([{ cursor: 10n, body: liveState('Staged') }]);
+ const { factory, sockets } = mockWsFactory();
+
+ const client = connect({
+ baseUrl: 'http://director.local:8080',
+ scope: SCOPE,
+ fetch,
+ webSocketFactory: factory
+ });
+ await flush();
+
+ // Snapshot was fetched and applied.
+ expect(calls).toHaveLength(1);
+ expect(calls[0]).toContain('/snapshot?scope=');
+ expect(client.getState().cursor).toBe(10n);
+ expect(phaseOf(client.getState().body)).toBe('Staged');
+
+ // A socket was opened; on open it sends a SubscribeRequest resuming from 10.
+ expect(sockets).toHaveLength(1);
+ sockets[0].open();
+ expect(sockets[0].sent).toHaveLength(1);
+ const req = JSON.parse(sockets[0].sent[0]);
+ expect(req.scope).toEqual(SCOPE);
+ expect(req.from).toBe(10); // cursor serialized as a JSON number (serde u64 default)
+ expect(client.getState().status).toBe('live');
+
+ client.close();
+ });
+
+ it('applies an ordered change stream in sequence and stays converged', async () => {
+ const { fetch } = mockFetch([{ cursor: 0n, body: liveState('Scheduled') }]);
+ const { factory, sockets } = mockWsFactory();
+ const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory });
+ await flush();
+ sockets[0].open();
+
+ sockets[0].emit(envelope(1n, 'Staged'));
+ sockets[0].emit(envelope(2n, 'Armed'));
+ sockets[0].emit(envelope(3n, 'Running'));
+
+ expect(client.getState().cursor).toBe(3n);
+ expect(phaseOf(client.getState().body)).toBe('Running');
+
+ client.close();
+ });
+
+ it('is idempotent: re-delivered envelopes at/below the cursor are no-ops', async () => {
+ const { fetch } = mockFetch([{ cursor: 0n, body: liveState('Scheduled') }]);
+ const { factory, sockets } = mockWsFactory();
+ const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory });
+ await flush();
+ sockets[0].open();
+
+ sockets[0].emit(envelope(1n, 'Staged'));
+ sockets[0].emit(envelope(2n, 'Armed'));
+ // Re-deliver 1 and 2 (at-least-once): they must not regress the state.
+ sockets[0].emit(envelope(1n, 'Scheduled'));
+ sockets[0].emit(envelope(2n, 'Scheduled'));
+
+ expect(client.getState().cursor).toBe(2n);
+ expect(phaseOf(client.getState().body)).toBe('Armed');
+
+ client.close();
+ });
+
+ it('re-snapshots on a sequence gap, then resumes from the fresh cursor', async () => {
+ const { fetch, calls } = mockFetch([
+ { cursor: 0n, body: liveState('Scheduled') }, // initial snapshot
+ { cursor: 5n, body: liveState('Running') } // re-snapshot after the gap
+ ]);
+ const { factory, sockets } = mockWsFactory();
+ const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory });
+ await flush();
+ sockets[0].open();
+
+ sockets[0].emit(envelope(1n, 'Staged'));
+ // Gap: jump to 4 (missed 2 and 3). The client must re-snapshot.
+ sockets[0].emit(envelope(4n, 'Armed'));
+ await flush();
+
+ // A second snapshot fetch happened and the old socket was torn down.
+ expect(calls).toHaveLength(2);
+ expect(sockets[0].closed).toBe(true);
+ expect(client.getState().cursor).toBe(5n);
+ expect(phaseOf(client.getState().body)).toBe('Running');
+
+ // A fresh socket re-subscribes from the new cursor (5).
+ expect(sockets).toHaveLength(2);
+ sockets[1].open();
+ const req = JSON.parse(sockets[1].sent[0]);
+ expect(req.from).toBe(5);
+
+ // The stream continues converged from 6.
+ sockets[1].emit(envelope(6n, 'Landed'));
+ expect(client.getState().cursor).toBe(6n);
+ expect(phaseOf(client.getState().body)).toBe('Landed');
+
+ client.close();
+ });
+
+ it('re-snapshots when the server reports a stale cursor', async () => {
+ const { fetch, calls } = mockFetch([
+ { cursor: 100n, body: liveState('Staged') },
+ { cursor: 200n, body: liveState('Running') }
+ ]);
+ const { factory, sockets } = mockWsFactory();
+ const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory });
+ await flush();
+ sockets[0].open();
+
+ const staleErr: ProtocolError = { code: 'StaleCursor', message: 'cursor too old to replay' };
+ sockets[0].emit({ error: staleErr });
+ await flush();
+
+ expect(calls).toHaveLength(2);
+ expect(client.getState().cursor).toBe(200n);
+ expect(sockets).toHaveLength(2);
+
+ client.close();
+ });
+
+ it('reconnects on socket drop and resumes from the last-applied cursor', async () => {
+ const { fetch, calls } = mockFetch([{ cursor: 0n, body: liveState('Scheduled') }]);
+ const { factory, sockets } = mockWsFactory();
+ const timer = manualTimer();
+ const client = connect({
+ baseUrl: 'http://d',
+ scope: SCOPE,
+ fetch,
+ webSocketFactory: factory,
+ setTimer: timer.setTimer,
+ clearTimer: timer.clearTimer,
+ reconnectDelayMs: 5
+ });
+ await flush();
+ sockets[0].open();
+ sockets[0].emit(envelope(1n, 'Staged'));
+ sockets[0].emit(envelope(2n, 'Armed'));
+
+ // Socket drops.
+ sockets[0].drop();
+ expect(client.getState().status).toBe('reconnecting');
+ expect(timer.pending()).toBe(true);
+
+ // Reconnect fires: it resumes by re-subscribing (no re-snapshot needed).
+ timer.fire();
+ await flush();
+ expect(calls).toHaveLength(1); // no extra snapshot — resumed by cursor
+ expect(sockets).toHaveLength(2);
+
+ sockets[1].open();
+ const req = JSON.parse(sockets[1].sent[0]);
+ expect(req.from).toBe(2);
+ expect(client.getState().status).toBe('live');
+
+ // Stream continues converged.
+ sockets[1].emit(envelope(3n, 'Running'));
+ expect(client.getState().cursor).toBe(3n);
+
+ client.close();
+ });
+
+ it('notifies onState listeners and stops after close', async () => {
+ const { fetch } = mockFetch([{ cursor: 0n, body: liveState('Scheduled') }]);
+ const { factory, sockets } = mockWsFactory();
+ const client = connect({ baseUrl: 'http://d', scope: SCOPE, fetch, webSocketFactory: factory });
+
+ const seen: (HeatPhase | undefined)[] = [];
+ const unsub = client.onState((s) => seen.push(phaseOf(s.body)));
+ await flush();
+ sockets[0].open();
+ sockets[0].emit(envelope(1n, 'Staged'));
+
+ expect(seen).toContain('Scheduled');
+ expect(seen).toContain('Staged');
+
+ unsub();
+ const before = seen.length;
+ sockets[0].emit(envelope(2n, 'Armed'));
+ expect(seen.length).toBe(before); // unsubscribed: no more notifications
+
+ client.close();
+ expect(client.getState().status).toBe('closed');
+ });
+});
diff --git a/frontend/packages/protocol-client/src/client.ts b/frontend/packages/protocol-client/src/client.ts
new file mode 100644
index 0000000..01598aa
--- /dev/null
+++ b/frontend/packages/protocol-client/src/client.ts
@@ -0,0 +1,458 @@
+/**
+ * The protocol client implementation.
+ *
+ * Lifecycle (protocol.html §2–§3):
+ *
+ * 1. `connect()` → GET the scoped snapshot. The snapshot carries the projection
+ * `body` plus the `cursor` the stream resumes from.
+ * 2. Open a WebSocket and send a `SubscribeRequest { scope, from: cursor }`.
+ * 3. Apply each incoming `ChangeEnvelope` in strictly increasing `sequence`
+ * order. Application is idempotent and keyed by `sequence`: an envelope at or
+ * below the last-applied cursor is a no-op (at-least-once, deduplicated).
+ * 4. A *gap* (an envelope whose sequence skips past `lastApplied + 1`) means we
+ * missed envelopes the stream cannot replay → re-snapshot and re-subscribe
+ * from the fresh cursor.
+ * 5. On socket drop, reconnect and resume from the last-applied cursor; if the
+ * server reports the cursor is too old (`StaleCursor`) — or resume otherwise
+ * fails — fall back to a re-snapshot.
+ */
+
+import type {
+ ChangeEnvelope,
+ Cursor,
+ ProjectionBody,
+ ProtocolError,
+ Scope,
+ Snapshot,
+ SubscribeRequest
+} from '@gridfpv/types';
+
+/** Minimal `fetch` surface this client needs (injectable for tests / Node). */
+export type FetchLike = (input: string, init?: RequestInit) => Promise;
+
+/**
+ * Minimal `WebSocket` surface this client needs — a structural subset of the DOM
+ * `WebSocket` so tests can inject a mock and Node can supply a polyfill.
+ */
+export interface WebSocketLike {
+ send(data: string): void;
+ close(code?: number, reason?: string): void;
+ onopen: ((this: WebSocketLike, ev: unknown) => unknown) | null;
+ onclose: ((this: WebSocketLike, ev: unknown) => unknown) | null;
+ onerror: ((this: WebSocketLike, ev: unknown) => unknown) | null;
+ onmessage: ((this: WebSocketLike, ev: { data: unknown }) => unknown) | null;
+}
+
+/** Factory that opens a {@link WebSocketLike} for a `ws(s)://…` URL. */
+export type WebSocketFactory = (url: string) => WebSocketLike;
+
+/** Where the connection is in its snapshot/stream lifecycle. */
+export type ConnectionStatus =
+ | 'connecting'
+ | 'snapshotting'
+ | 'subscribing'
+ | 'live'
+ | 'reconnecting'
+ | 'closed';
+
+/** The current typed projection state the client exposes to consumers. */
+export interface ProtocolState {
+ /** The materialized projection body, or `undefined` before the first snapshot. */
+ readonly body: ProjectionBody | undefined;
+ /** The last-applied stream cursor (snapshot cursor, advanced by each envelope). */
+ readonly cursor: Cursor | undefined;
+ /** Lifecycle status. */
+ readonly status: ConnectionStatus;
+ /** The last protocol/transport error, if the connection is degraded. */
+ readonly error: ProtocolError | undefined;
+}
+
+/** A listener notified on every state change. Returns an unsubscribe function. */
+export type StateListener = (state: ProtocolState) => void;
+
+/** Options for {@link connect}. */
+export interface ConnectOptions {
+ /**
+ * Base URL of the Director (or Cloud) protocol server, e.g.
+ * `http://director.local:8080` or `https://cloud.gridfpv.example`. The client
+ * is configured with the base URL *only*, so it cannot tell LAN from Cloud.
+ */
+ baseUrl: string;
+ /** The resource this connection is scoped to (protocol.html §4). */
+ scope: Scope;
+ /** Optional bearer token (sent as `Authorization: Bearer …` and on the WS URL). */
+ token?: string;
+ /** Inject a `fetch` (defaults to the global). Used by tests and Node. */
+ fetch?: FetchLike;
+ /** Inject a WebSocket factory (defaults to the global `WebSocket`). */
+ webSocketFactory?: WebSocketFactory;
+ /**
+ * Reconnect backoff in ms (delay before re-opening a dropped socket).
+ * Defaults to 1000ms. A timer of `0` reconnects on the next tick.
+ */
+ reconnectDelayMs?: number;
+ /** Inject a timer (defaults to `setTimeout`). Used by tests. */
+ setTimer?: (cb: () => void, ms: number) => unknown;
+ /** Inject a timer-clear (defaults to `clearTimeout`). Used by tests. */
+ clearTimer?: (handle: unknown) => void;
+}
+
+/**
+ * A live connection to the protocol server: snapshot + WS subscribe, exposing the
+ * current typed projection state via a framework-agnostic subscribe API.
+ */
+export interface ProtocolClient {
+ readonly baseUrl: string;
+ readonly scope: Scope;
+ /** A synchronous snapshot of the current state. */
+ getState(): ProtocolState;
+ /**
+ * Subscribe to state changes. The listener is invoked immediately with the
+ * current state, then on every subsequent change. Returns an unsubscribe fn.
+ */
+ onState(listener: StateListener): () => void;
+ /** Close the connection and tear down the WebSocket. Idempotent. */
+ close(): void;
+}
+
+/** Error frame the server may send on the stream (protocol.html §9.8). */
+interface ErrorFrame {
+ error: ProtocolError;
+}
+
+// ── Cursor (bigint) wire handling ──────────────────────────────────────────────
+//
+// `Cursor` is a u64 rendered as a TS `bigint` (bindings/Cursor.ts). On the wire it
+// arrives as a JSON number or string depending on the server's serializer; `bigint`
+// values, conversely, are not serializable by `JSON.stringify`. These two helpers
+// bracket that mismatch so cursors stay precise (a u64 can exceed JS's safe-integer
+// range) and the rest of the client works in `bigint`.
+
+/** Coerce a wire cursor (number | string | bigint) to a `bigint`. */
+function toCursor(v: unknown): Cursor {
+ if (typeof v === 'bigint') return v;
+ if (typeof v === 'number') return BigInt(Math.trunc(v));
+ if (typeof v === 'string' && v.length > 0) return BigInt(v);
+ throw new Error(`invalid cursor: ${String(v)}`);
+}
+
+/**
+ * `JSON.stringify` with bigints rendered as JSON numbers (serde's u64 default),
+ * since `JSON.stringify` cannot serialize a bigint directly. Cursors past 2^53
+ * lose precision in this number form; if a deployment ever needs full-u64 cursors
+ * on the wire the server would serialize them as strings and this would follow.
+ */
+function stringifyWire(value: unknown): string {
+ return JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? Number(v) : v));
+}
+
+/** Normalize a parsed snapshot so its cursor is a `bigint`. */
+function normalizeSnapshot(data: Snapshot): Snapshot {
+ return { ...data, cursor: toCursor((data as { cursor: unknown }).cursor) };
+}
+
+/** Normalize a parsed envelope so its sequence is a `bigint`. */
+function normalizeEnvelope(env: ChangeEnvelope): ChangeEnvelope {
+ return { ...env, sequence: toCursor((env as { sequence: unknown }).sequence) };
+}
+
+const isProtocolError = (v: unknown): v is ProtocolError =>
+ typeof v === 'object' &&
+ v !== null &&
+ 'code' in v &&
+ 'message' in v &&
+ typeof (v as ProtocolError).message === 'string';
+
+const isChangeEnvelope = (v: unknown): v is ChangeEnvelope =>
+ typeof v === 'object' && v !== null && 'sequence' in v && 'projection' in v && 'change' in v;
+
+const isErrorFrame = (v: unknown): v is ErrorFrame =>
+ typeof v === 'object' && v !== null && 'error' in v && isProtocolError((v as ErrorFrame).error);
+
+/** Map an http(s) base URL to its ws(s) equivalent. */
+function toWebSocketBase(baseUrl: string): string {
+ if (baseUrl.startsWith('https://')) return 'wss://' + baseUrl.slice('https://'.length);
+ if (baseUrl.startsWith('http://')) return 'ws://' + baseUrl.slice('http://'.length);
+ return baseUrl;
+}
+
+const trimSlash = (s: string): string => (s.endsWith('/') ? s.slice(0, -1) : s);
+
+/**
+ * Connect to a GridFPV protocol server and begin the snapshot→subscribe handshake.
+ *
+ * Returns immediately with a {@link ProtocolClient}; the snapshot fetch and WS
+ * subscribe proceed asynchronously and surface through the state/`onState` API.
+ */
+export function connect(options: ConnectOptions): ProtocolClient {
+ const fetchImpl: FetchLike = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
+ const wsFactory: WebSocketFactory =
+ options.webSocketFactory ??
+ ((url) => new globalThis.WebSocket(url) as unknown as WebSocketLike);
+ const setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
+ const clearTimer =
+ options.clearTimer ?? ((h) => clearTimeout(h as ReturnType));
+ const reconnectDelayMs = options.reconnectDelayMs ?? 1000;
+
+ const baseUrl = trimSlash(options.baseUrl);
+ const wsBase = trimSlash(toWebSocketBase(options.baseUrl));
+ const scope = options.scope;
+ const token = options.token;
+ const scopeParam = encodeURIComponent(JSON.stringify(scope));
+
+ // ── Mutable connection state ───────────────────────────────────────────────
+ let body: ProjectionBody | undefined;
+ let cursor: Cursor | undefined;
+ let status: ConnectionStatus = 'connecting';
+ let lastError: ProtocolError | undefined;
+
+ let ws: WebSocketLike | null = null;
+ let closed = false;
+ let reconnectHandle: unknown = null;
+ /** Bumps every (re)connect attempt so stale callbacks from an old socket no-op. */
+ let generation = 0;
+
+ const listeners = new Set();
+
+ const snapshot = (): ProtocolState => ({ body, cursor, status, error: lastError });
+
+ function emit(): void {
+ const s = snapshot();
+ for (const l of listeners) l(s);
+ }
+
+ function setStatus(next: ConnectionStatus): void {
+ if (status !== next) {
+ status = next;
+ emit();
+ }
+ }
+
+ function fail(err: ProtocolError): void {
+ lastError = err;
+ emit();
+ }
+
+ // ── Snapshot (protocol.html §2) ────────────────────────────────────────────
+ async function fetchSnapshot(gen: number): Promise {
+ setStatus('snapshotting');
+ const headers: Record = { Accept: 'application/json' };
+ if (token) headers.Authorization = `Bearer ${token}`;
+ let resp: Response;
+ try {
+ resp = await fetchImpl(`${baseUrl}/snapshot?scope=${scopeParam}`, { headers });
+ } catch (e) {
+ if (gen !== generation || closed) return false;
+ fail({ code: 'Internal', message: `snapshot fetch failed: ${String(e)}` });
+ return false;
+ }
+ if (gen !== generation || closed) return false;
+ if (!resp.ok) {
+ let err: ProtocolError = { code: 'Internal', message: `snapshot HTTP ${resp.status}` };
+ try {
+ const data: unknown = await resp.json();
+ if (isProtocolError(data)) err = data;
+ } catch {
+ /* keep the HTTP-status error */
+ }
+ fail(err);
+ return false;
+ }
+ const data = normalizeSnapshot((await resp.json()) as Snapshot);
+ if (gen !== generation || closed) return false;
+ body = data.body;
+ cursor = data.cursor;
+ lastError = undefined;
+ emit();
+ return true;
+ }
+
+ // ── Apply one ordered change envelope (protocol.html §3) ────────────────────
+ //
+ // Returns 'applied', 'duplicate' (already seen — idempotent no-op), or 'gap'
+ // (missed envelopes → caller must re-snapshot).
+ function applyEnvelope(env: ChangeEnvelope): 'applied' | 'duplicate' | 'gap' {
+ const seq = env.sequence;
+ if (cursor !== undefined) {
+ // Idempotent, keyed by sequence: anything at or below the cursor is a no-op.
+ if (seq <= cursor) return 'duplicate';
+ // The stream is contiguous: the next envelope must be exactly cursor + 1.
+ if (seq !== cursor + 1n) return 'gap';
+ }
+ const change = env.change;
+ if ('FreshValue' in change) {
+ body = change.FreshValue;
+ } else {
+ // Delta. The per-projection delta encodings are deferred (#43): the wire
+ // type carries an opaque payload today. We advance the cursor so ordering
+ // and resume stay correct; once #43 pins the typed deltas, fold them into
+ // `body` here per ProjectionKind. Until then a delta cannot mutate `body`,
+ // and a re-snapshot (always correct, §3) reconciles any drift.
+ void change.Delta;
+ }
+ cursor = seq;
+ return 'applied';
+ }
+
+ // ── WebSocket subscribe + stream (protocol.html §3) ─────────────────────────
+ function openSocket(gen: number): void {
+ if (gen !== generation || closed) return;
+ setStatus('subscribing');
+ const url = token ? `${wsBase}/stream?token=${encodeURIComponent(token)}` : `${wsBase}/stream`;
+ let socket: WebSocketLike;
+ try {
+ socket = wsFactory(url);
+ } catch (e) {
+ scheduleReconnect(gen, { code: 'Internal', message: `WS open failed: ${String(e)}` });
+ return;
+ }
+ ws = socket;
+
+ socket.onopen = () => {
+ if (gen !== generation || closed) return;
+ const req: SubscribeRequest = { scope, from: cursor };
+ socket.send(stringifyWire(req));
+ setStatus('live');
+ };
+
+ socket.onmessage = (ev) => {
+ if (gen !== generation || closed) return;
+ void handleMessage(gen, ev.data);
+ };
+
+ socket.onerror = () => {
+ /* surfaced via onclose; nothing actionable here */
+ };
+
+ socket.onclose = () => {
+ if (gen !== generation || closed) return;
+ scheduleReconnect(gen, undefined);
+ };
+ }
+
+ async function handleMessage(gen: number, raw: unknown): Promise {
+ let parsed: unknown;
+ try {
+ parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
+ } catch {
+ fail({ code: 'BadRequest', message: 'malformed stream frame' });
+ return;
+ }
+
+ if (isErrorFrame(parsed)) {
+ // A stale cursor means resume is impossible → re-snapshot from scratch (§3).
+ if (parsed.error.code === 'StaleCursor') {
+ await resnapshot(gen);
+ } else {
+ fail(parsed.error);
+ }
+ return;
+ }
+
+ if (!isChangeEnvelope(parsed)) {
+ // Unknown frame (e.g. a ServerHello in a richer handshake) — ignore it so an
+ // additive protocol change doesn't break an older client (§7).
+ return;
+ }
+
+ const result = applyEnvelope(normalizeEnvelope(parsed));
+ if (result === 'gap') {
+ // Missed envelopes the stream can't replay → re-snapshot and re-subscribe.
+ await resnapshot(gen);
+ return;
+ }
+ if (result === 'applied') emit();
+ }
+
+ // Re-snapshot in place (gap or stale cursor), then re-subscribe from the fresh
+ // cursor. The existing socket is torn down so the new subscribe is unambiguous.
+ async function resnapshot(gen: number): Promise {
+ if (gen !== generation || closed) return;
+ teardownSocket();
+ const ok = await fetchSnapshot(gen);
+ if (gen !== generation || closed) return;
+ if (ok) openSocket(gen);
+ else scheduleReconnect(gen, lastError);
+ }
+
+ function scheduleReconnect(gen: number, err: ProtocolError | undefined): void {
+ if (gen !== generation || closed) return;
+ teardownSocket();
+ if (err) lastError = err;
+ setStatus('reconnecting');
+ reconnectHandle = setTimer(() => {
+ if (closed) return;
+ reconnect();
+ }, reconnectDelayMs);
+ }
+
+ // Reconnect (protocol.html §3): resume from the last-applied cursor by
+ // re-subscribing; if we never got a snapshot, take one first. A StaleCursor on
+ // resume drives a re-snapshot via the stream error path.
+ function reconnect(): void {
+ if (closed) return;
+ const gen = ++generation;
+ if (cursor === undefined) {
+ void (async () => {
+ const ok = await fetchSnapshot(gen);
+ if (gen !== generation || closed) return;
+ if (ok) openSocket(gen);
+ else scheduleReconnect(gen, lastError);
+ })();
+ } else {
+ openSocket(gen);
+ }
+ }
+
+ function teardownSocket(): void {
+ if (reconnectHandle !== null) {
+ clearTimer(reconnectHandle);
+ reconnectHandle = null;
+ }
+ if (ws) {
+ const dead = ws;
+ ws = null;
+ dead.onopen = null;
+ dead.onclose = null;
+ dead.onerror = null;
+ dead.onmessage = null;
+ try {
+ dead.close();
+ } catch {
+ /* ignore */
+ }
+ }
+ }
+
+ // ── Kick off the handshake ─────────────────────────────────────────────────
+ function start(): void {
+ const gen = ++generation;
+ void (async () => {
+ const ok = await fetchSnapshot(gen);
+ if (gen !== generation || closed) return;
+ if (ok) openSocket(gen);
+ else scheduleReconnect(gen, lastError);
+ })();
+ }
+
+ start();
+
+ return {
+ baseUrl,
+ scope,
+ getState: snapshot,
+ onState(listener: StateListener): () => void {
+ listeners.add(listener);
+ listener(snapshot());
+ return () => listeners.delete(listener);
+ },
+ close(): void {
+ if (closed) return;
+ closed = true;
+ generation++;
+ teardownSocket();
+ setStatus('closed');
+ listeners.clear();
+ }
+ };
+}
diff --git a/frontend/packages/protocol-client/src/index.ts b/frontend/packages/protocol-client/src/index.ts
index b18b429..06e4161 100644
--- a/frontend/packages/protocol-client/src/index.ts
+++ b/frontend/packages/protocol-client/src/index.ts
@@ -1,55 +1,25 @@
/**
- * @gridfpv/protocol-client — STUB.
+ * @gridfpv/protocol-client — the thin, framework-agnostic protocol layer
+ * described in docs/clients.html §3 and docs/protocol.html §2–§4.
*
- * The thin, framework-agnostic protocol layer described in docs/clients.html §3:
- * connect to a base URL, fetch a projection snapshot, subscribe to the WebSocket
- * change stream, and expose typed state. It is configured only with a base URL,
- * so it cannot tell LAN from Cloud — the same client backs all three surfaces on
- * both transports.
+ * It connects to a single base URL (so it cannot tell LAN from Cloud — the same
+ * client backs all three surfaces on both transports), performs the
+ * "snapshot first, then subscribe" handshake (protocol.html §2), applies the
+ * ordered change-envelope stream idempotently in sequence order (§3), and resumes
+ * by cursor — falling back to a re-snapshot — across gaps and reconnects.
*
- * This package currently only nails down the public surface so apps can wire
- * against it. The real implementation (snapshot fetch, WS reconnect, typed
- * subscriptions, auth headers) is issue #49.
+ * Everything is typed against the ts-rs–generated wire types re-exported from
+ * `@gridfpv/types`; this package hand-writes no wire shape of its own.
*/
-import type { RaceSnapshot } from '@gridfpv/types';
-/** Options for {@link connect}. Expanded by #49 (auth token, transports, etc.). */
-export interface ConnectOptions {
- /**
- * Base URL of the Director (or Cloud) protocol server, e.g.
- * `http://director.local:8080` or `https://cloud.gridfpv.example`.
- */
- baseUrl: string;
-}
-
-/**
- * A live connection to the protocol server. The shape here is a placeholder; #49
- * defines the real snapshot/subscribe/typed-state API.
- */
-export interface ProtocolClient {
- readonly baseUrl: string;
- /** Fetch the current projection snapshot. Implemented by #49. */
- snapshot(): Promise;
- /** Close the connection and tear down any WebSocket. Implemented by #49. */
- close(): void;
-}
-
-/**
- * Connect to a GridFPV protocol server.
- *
- * STUB: signature only. #49 implements snapshot + WS subscribe.
- *
- * @param options - connection options, or a bare base URL string for convenience.
- */
-export function connect(options: ConnectOptions | string): ProtocolClient {
- const baseUrl = typeof options === 'string' ? options : options.baseUrl;
- return {
- baseUrl,
- snapshot(): Promise {
- return Promise.reject(new Error('protocol-client: connect() is a stub — implemented by #49'));
- },
- close(): void {
- /* no-op until #49 */
- }
- };
-}
+export { connect } from './client.js';
+export type {
+ ConnectOptions,
+ ProtocolClient,
+ ProtocolState,
+ ConnectionStatus,
+ StateListener,
+ WebSocketLike,
+ WebSocketFactory,
+ FetchLike
+} from './client.js';
diff --git a/frontend/packages/protocol-client/tsconfig.json b/frontend/packages/protocol-client/tsconfig.json
index 5a24989..67c4b4e 100644
--- a/frontend/packages/protocol-client/tsconfig.json
+++ b/frontend/packages/protocol-client/tsconfig.json
@@ -4,5 +4,8 @@
"outDir": "dist",
"rootDir": "src"
},
- "include": ["src"]
+ "include": ["src"],
+ // Tests are type-checked and run by Vitest (vitest.config.ts), not shipped in
+ // the package build.
+ "exclude": ["src/**/*.test.ts"]
}
diff --git a/frontend/packages/protocol-client/tsconfig.test.json b/frontend/packages/protocol-client/tsconfig.test.json
new file mode 100644
index 0000000..9f3c4d9
--- /dev/null
+++ b/frontend/packages/protocol-client/tsconfig.test.json
@@ -0,0 +1,10 @@
+{
+ // Type-checks the test sources (which the build tsconfig excludes from `dist`).
+ // Used by the `check` script; Vitest runs them at runtime.
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "types": ["vitest/globals"]
+ },
+ "include": ["src"]
+}
diff --git a/frontend/packages/protocol-client/vitest.config.ts b/frontend/packages/protocol-client/vitest.config.ts
new file mode 100644
index 0000000..4aa70b2
--- /dev/null
+++ b/frontend/packages/protocol-client/vitest.config.ts
@@ -0,0 +1,19 @@
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vitest/config';
+
+// The client is typed against `@gridfpv/types`, whose source re-exports the
+// ts-rs bindings through the `@bindings/*` alias (see tsconfig.base.json). Vitest
+// uses Vite's resolver, not tsconfig `paths`, so mirror the alias here pointing at
+// the repo-root `bindings/`. Everything imported at runtime is `export type`, so
+// nothing of the bindings actually executes — this only satisfies resolution.
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@bindings': fileURLToPath(new URL('../../../bindings', import.meta.url))
+ }
+ },
+ test: {
+ environment: 'node',
+ include: ['src/**/*.test.ts']
+ }
+});
diff --git a/frontend/packages/types/package.json b/frontend/packages/types/package.json
index 9260ee4..fa699ac 100644
--- a/frontend/packages/types/package.json
+++ b/frontend/packages/types/package.json
@@ -14,7 +14,7 @@
},
"scripts": {
"build": "tsc -p tsconfig.json",
- "check": "tsc -p tsconfig.json --noEmit"
+ "check": "tsc -p tsconfig.json"
},
"devDependencies": {
"typescript": "^5.7.2"
diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts
index 3719902..ebd4462 100644
--- a/frontend/packages/types/src/generated.ts
+++ b/frontend/packages/types/src/generated.ts
@@ -1,41 +1,70 @@
/**
- * Adapter to the ts-rs–generated protocol bindings.
+ * Adapter to the ts-rs–generated protocol bindings — the single seam that knows
+ * where the generated wire types physically live.
*
* The wire types are generated from the Rust server crate into the repo-root
* `bindings/` directory (one file per type), per docs/clients.html §3 and
- * architecture.html §6. The frontend NEVER hand-writes a wire type — this file
- * is the only seam that knows where the generated types physically live.
+ * architecture.html §6. The frontend NEVER hand-writes a wire type.
*
- * ── When real bindings exist ────────────────────────────────────────────────
- * `bindings/` is expected to expose a barrel (e.g. `bindings/index.ts`). Replace
- * the placeholder block below with a single re-export, resolved through the
- * `@bindings/*` tsconfig path alias:
+ * ── How this barrel works ───────────────────────────────────────────────────
+ * ts-rs emits one file per type (`bindings/Snapshot.ts`, `bindings/Cursor.ts`, …)
+ * with extensionless cross-imports and no `index.ts` barrel of its own. This file
+ * is that barrel: it re-exports every generated type module, resolved through the
+ * `@bindings/*` tsconfig path alias (`@bindings/* → ../../../bindings/*`, set in
+ * packages/types/tsconfig.json — the one place that knows their physical location).
*
- * export * from '@bindings/index';
+ * Because everything downstream imports from `@gridfpv/types`, a contract change in
+ * Rust (a renamed field, a removed variant) surfaces here — and in every consumer —
+ * as a TypeScript compile error rather than silent drift.
*
- * (or re-export the specific generated modules you need). Nothing else in the
- * frontend changes, because everything imports from `@gridfpv/types`.
+ * ── Regenerating ────────────────────────────────────────────────────────────
+ * The Rust side regenerates `bindings/*.ts` (`cargo xtask gen`). When a *new* type
+ * is added, add a matching `export type * from '@bindings/';` line below;
+ * when one is removed, drop its line. Nothing else in the frontend changes.
*
- * ── Standalone fallback (bindings/ absent) ──────────────────────────────────
- * Until the Rust generation step has run — e.g. a frontend-only checkout or CI
- * that builds the frontend in isolation — `bindings/` may not exist. To keep the
- * monorepo buildable and type-checkable on its own, we define a minimal set of
- * placeholder types here. These are intentionally thin and exist only so the
- * scaffold compiles; they are replaced wholesale by the generated re-export.
+ * This list is kept in lockstep with `bindings/` (one line per `bindings/*.ts`).
*/
-/** Opaque identifier for a pilot. Generated type will supersede this. */
-export type PilotId = string;
-
-/** Opaque identifier for a race/heat. Generated type will supersede this. */
-export type RaceId = string;
-
-/**
- * Placeholder snapshot shape. The real, ts-rs–generated projection snapshot
- * type will replace this once `bindings/` is populated.
- */
-export interface RaceSnapshot {
- raceId: RaceId;
- /** Pilots in finishing/standings order. */
- pilots: PilotId[];
-}
+export type * from '@bindings/AdapterId';
+export type * from '@bindings/Change';
+export type * from '@bindings/ChangeEnvelope';
+export type * from '@bindings/ClassId';
+export type * from '@bindings/Command';
+export type * from '@bindings/CommandAck';
+export type * from '@bindings/CompetitorKey';
+export type * from '@bindings/CompetitorLaps';
+export type * from '@bindings/CompetitorRef';
+export type * from '@bindings/CompletedHeat';
+export type * from '@bindings/ContractVersion';
+export type * from '@bindings/Cursor';
+export type * from '@bindings/ErrorCode';
+export type * from '@bindings/Event';
+export type * from '@bindings/EventId';
+export type * from '@bindings/EventOutcome';
+export type * from '@bindings/GateIndex';
+export type * from '@bindings/HeatId';
+export type * from '@bindings/HeatPhase';
+export type * from '@bindings/HeatResult';
+export type * from '@bindings/HeatTransition';
+export type * from '@bindings/Hello';
+export type * from '@bindings/Lap';
+export type * from '@bindings/LapList';
+export type * from '@bindings/LiveRaceState';
+export type * from '@bindings/LogRef';
+export type * from '@bindings/Metric';
+export type * from '@bindings/Pass';
+export type * from '@bindings/Penalty';
+export type * from '@bindings/PilotId';
+export type * from '@bindings/Placement';
+export type * from '@bindings/ProjectionBody';
+export type * from '@bindings/ProjectionKind';
+export type * from '@bindings/ProtocolError';
+export type * from '@bindings/RankEntry';
+export type * from '@bindings/Scope';
+export type * from '@bindings/ServerHello';
+export type * from '@bindings/SessionId';
+export type * from '@bindings/SignalContext';
+export type * from '@bindings/Snapshot';
+export type * from '@bindings/SourceTime';
+export type * from '@bindings/SubscribeRequest';
+export type * from '@bindings/WinCondition';
diff --git a/frontend/packages/types/src/index.ts b/frontend/packages/types/src/index.ts
index e80efc8..a2760c7 100644
--- a/frontend/packages/types/src/index.ts
+++ b/frontend/packages/types/src/index.ts
@@ -3,10 +3,10 @@
*
* Every app and package imports protocol/wire types from here:
*
- * import type { RaceSnapshot, PilotId } from '@gridfpv/types';
+ * import type { Snapshot, ChangeEnvelope, Command, LapList } from '@gridfpv/types';
*
- * The actual definitions come from the ts-rs–generated bindings (see
- * ./generated.ts for how regenerated bindings flow in, and the standalone
- * fallback used when `bindings/` is absent).
+ * The actual definitions come from the ts-rs–generated bindings; see
+ * ./generated.ts for how the barrel re-exports each `bindings/*.ts` and how
+ * regenerated bindings flow in.
*/
export * from './generated.js';
diff --git a/frontend/packages/types/tsconfig.json b/frontend/packages/types/tsconfig.json
index 52d7761..9390d10 100644
--- a/frontend/packages/types/tsconfig.json
+++ b/frontend/packages/types/tsconfig.json
@@ -1,16 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
- "outDir": "dist",
- "rootDir": "src",
- "baseUrl": ".",
- "paths": {
- // The generated ts-rs bindings live at the repo root. The `@bindings/*`
- // alias is the one place that knows their physical location, so apps and
- // packages only ever import from `@gridfpv/types`. When real bindings
- // exist, src/generated.ts re-exports from here.
- "@bindings/*": ["../../../bindings/*"]
- }
+ // This package is a pure type seam: every `export` in src/ is `export type`,
+ // so there is no JS to emit and consumers resolve `@gridfpv/types` straight to
+ // `src/index.ts` (see package.json `exports`). We type-check only (`noEmit`)
+ // rather than emit a `dist/`, which also lets the `@bindings/*` re-exports
+ // (resolved via the shared base-config alias) live outside this package's tree
+ // without tripping `rootDir`.
+ "noEmit": true,
+ "declaration": false
},
"include": ["src"]
}
diff --git a/frontend/tsconfig.base.json b/frontend/tsconfig.base.json
index 44e4ae6..5853529 100644
--- a/frontend/tsconfig.base.json
+++ b/frontend/tsconfig.base.json
@@ -1,5 +1,16 @@
{
"compilerOptions": {
+ // The ts-rs–generated bindings live at the repo root (one file per type). The
+ // `@bindings/*` alias is the single place that knows their physical location.
+ // It lives in the shared base config (resolved relative to this file in
+ // frontend/) so any workspace that follows an import chain into
+ // `@gridfpv/types` → src/generated.ts resolves the re-exports identically.
+ // Only `@gridfpv/types` should import from `@bindings/*` directly; everything
+ // else imports from `@gridfpv/types`.
+ "baseUrl": ".",
+ "paths": {
+ "@bindings/*": ["../bindings/*"]
+ },
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
From 895b6a2550f991490ff6c1b9d7324d4bf33a5e51 Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 04:07:06 +0000
Subject: [PATCH 006/362] Add WebSocket change-stream + sequence/resume engine
(#43)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implement the v0.4 realtime change stream (protocol.html §3, §9.1): a
WebSocket-only `GET /stream` endpoint that, after a client's snapshot,
keeps its scoped projection identical to the server's via ordered,
sequenced change envelopes.
- AppState gains an append-notification primitive (`tokio::sync::Notify`)
and `AppState::append(event, recorded_at)` — the single log-write that
also wakes every subscribed stream. The control path (#45) reuses it.
- `ws.rs` runs one stream per connection: read the one SubscribeRequest,
reject a resume cursor older than the bounded retained window
(RETAINED_WINDOW = 256 log offsets) with a StreamMessage::ReSnapshotRequired
(ErrorCode::StaleCursor), else replay from the cursor by folding the log
forward and emit a fresh-value ChangeEnvelope each time the scoped
projection changes, then tail new appends via the notify wakeup.
- Per-stream sequence (starts at 1, +1 per envelope) is kept distinct from
the resume cursor (a log offset, §9.5); the mapping is documented.
- New StreamMessage wire type (Change | ReSnapshotRequired) with regenerated
bindings. Delta-vs-fresh distinction is wired per projection (§9.2);
actual delta encodings stay deferred to #59 (fresh-value only for now).
- Integration tests spin the router on an ephemeral localhost port with
axum::serve + tokio-tungstenite (no Docker): ordered streaming from a
cursor, mid-cursor resume, too-old-cursor re-snapshot, no-op folds, and
malformed-subscribe close. Deterministic.
`cargo xtask ci` passes (incl. gen drift).
Part of #43.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
Cargo.lock | 49 +++-
bindings/StreamMessage.ts | 17 ++
crates/server/Cargo.toml | 17 +-
crates/server/src/app.rs | 58 ++++-
crates/server/src/lib.rs | 24 +-
crates/server/src/stream.rs | 50 ++++
crates/server/src/ws.rs | 431 +++++++++++++++++++++++++++++++
crates/server/tests/ws_stream.rs | 290 +++++++++++++++++++++
8 files changed, 914 insertions(+), 22 deletions(-)
create mode 100644 bindings/StreamMessage.ts
create mode 100644 crates/server/src/ws.rs
create mode 100644 crates/server/tests/ws_stream.rs
diff --git a/Cargo.lock b/Cargo.lock
index 0a87e71..4520ded 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -54,6 +54,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
+ "base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@@ -72,8 +73,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
+ "sha1",
"sync_wrapper",
"tokio",
+ "tokio-tungstenite 0.29.0",
"tower",
"tower-layer",
"tower-service",
@@ -494,6 +497,7 @@ name = "gridfpv-server"
version = "0.1.0"
dependencies = [
"axum",
+ "futures-util",
"gridfpv-engine",
"gridfpv-events",
"gridfpv-projection",
@@ -502,6 +506,7 @@ dependencies = [
"serde",
"serde_json",
"tokio",
+ "tokio-tungstenite 0.24.0",
"tower",
"ts-rs",
]
@@ -1191,7 +1196,7 @@ dependencies = [
"serde_json",
"thiserror 1.0.69",
"tokio",
- "tokio-tungstenite",
+ "tokio-tungstenite 0.21.0",
"tungstenite 0.21.0",
"url",
]
@@ -1619,6 +1624,30 @@ dependencies = [
"tungstenite 0.21.0",
]
+[[package]]
+name = "tokio-tungstenite"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite 0.24.0",
+]
+
+[[package]]
+name = "tokio-tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite 0.29.0",
+]
+
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -1746,6 +1775,24 @@ dependencies = [
"utf-8",
]
+[[package]]
+name = "tungstenite"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
+dependencies = [
+ "byteorder",
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.8.6",
+ "sha1",
+ "thiserror 1.0.69",
+ "utf-8",
+]
+
[[package]]
name = "tungstenite"
version = "0.29.0"
diff --git a/bindings/StreamMessage.ts b/bindings/StreamMessage.ts
new file mode 100644
index 0000000..1fb92e0
--- /dev/null
+++ b/bindings/StreamMessage.ts
@@ -0,0 +1,17 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { ChangeEnvelope } from "./ChangeEnvelope";
+import type { ProtocolError } from "./ProtocolError";
+
+/**
+ * A frame the server sends down the change-stream WebSocket (protocol.html §3).
+ *
+ * Almost every frame is a [`ChangeEnvelope`]; the one exception is the distinguished
+ * **re-snapshot-required** signal the server sends when a client resumes from a cursor
+ * older than the bounded retained window (§3 "fall back to re-snapshot"). Modelling
+ * both as one externally-tagged enum means a client reads a single message type off the
+ * socket and branches on the tag, rather than guessing whether a frame is data or a
+ * control signal.
+ *
+ * Externally tagged, mapping to a TS discriminated union.
+ */
+export type StreamMessage = { "Change": ChangeEnvelope } | { "ReSnapshotRequired": ProtocolError };
diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml
index fc68248..a972f82 100644
--- a/crates/server/Cargo.toml
+++ b/crates/server/Cargo.toml
@@ -15,14 +15,19 @@ serde.workspace = true
serde_json.workspace = true
ts-rs = "12"
-# The snapshot HTTP transport (#42). The WS change stream (#43) and the control path
-# (#45) build on the same axum `Router` and `AppState` defined in `app.rs`.
-axum = "0.8"
+# The snapshot HTTP transport (#42) and the WS change stream (#43) build on the same
+# axum `Router` and `AppState` defined in `app.rs`; the control path (#45) reuses
+# `AppState::append`. `ws` enables axum's WebSocket upgrade extractor.
+axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "macros", "net"] }
[dev-dependencies]
-# `tower::ServiceExt::oneshot` drives the router in integration tests with no real
-# network or Docker; `http-body-util` collects the response body to assert on it.
+# `tower::ServiceExt::oneshot` drives the snapshot router in integration tests with no
+# real network or Docker; `http-body-util` collects the response body to assert on it.
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
-tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
+tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
+# The WS change-stream integration tests (#43) spin the router on an ephemeral localhost
+# port via `axum::serve` and connect with a real WebSocket client (no Docker).
+tokio-tungstenite = "0.24"
+futures-util = "0.3"
diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs
index 9821cad..11b5310 100644
--- a/crates/server/src/app.rs
+++ b/crates/server/src/app.rs
@@ -83,6 +83,7 @@ use gridfpv_events::{CompetitorRef, Event, HeatId, SourceTime};
use gridfpv_projection::{LapList, lap_list_marshaled};
use gridfpv_storage::{EventLog, Offset, Result as StorageResult, StoredEvent};
use serde::Deserialize;
+use tokio::sync::Notify;
use crate::error::{ErrorCode, ProtocolError};
use crate::live_state::live_state;
@@ -142,10 +143,24 @@ pub type SharedLog = Arc>;
///
/// Holds the [`SharedLog`] — the single source of truth the snapshot reads fold, the WS
/// stream (#43) tails, and the control path (#45) appends through. Cloning an `AppState`
-/// clones the `Arc`, so all handlers and future tasks share one log.
+/// clones the `Arc`s, so all handlers and tasks share one log and one append signal.
+///
+/// # Append notification (the stream wakeup, #43)
+///
+/// The change stream is a long-lived task that, after replaying the log tail, must wake
+/// the *instant* a new event is appended so it can fold and push the next envelope. A
+/// [`tokio::sync::Notify`] is the wakeup: every [`append`](AppState::append) appends to
+/// the log and then `notify_waiters()`. A stream that has caught up to the log tail waits
+/// on `notified()`; the next append wakes it and it reads the new tail. `Notify` (rather
+/// than a `broadcast` channel) carries no payload — the event itself is read back from
+/// the log, the one source of truth — so a slow stream can never lag a bounded channel
+/// and miss an event; it always re-reads from where it left off. The control path (#45)
+/// drives the very same [`append`](AppState::append) so its writes wake every stream.
#[derive(Clone)]
pub struct AppState {
log: SharedLog,
+ /// Woken on every append so caught-up change streams re-read the log tail.
+ appended: Arc,
}
impl AppState {
@@ -155,13 +170,17 @@ impl AppState {
pub fn new(log: impl EventLog + Send + 'static) -> Self {
Self {
log: Arc::new(Mutex::new(log)),
+ appended: Arc::new(Notify::new()),
}
}
/// Build the state from an already-shared log handle — for when the WS stream (#43)
/// or control path (#45) needs to share the *same* `Arc>` with the router.
pub fn from_shared(log: SharedLog) -> Self {
- Self { log }
+ Self {
+ log,
+ appended: Arc::new(Notify::new()),
+ }
}
/// The shared log handle, for tasks that tail or append outside the router.
@@ -169,10 +188,38 @@ impl AppState {
Arc::clone(&self.log)
}
+ /// Append an event to the log **and wake every subscribed change stream**
+ /// (protocol.html §3) — the one write path the control endpoint (#45) reuses.
+ ///
+ /// Locks the log, appends through [`EventSource::append`] (assigning the next dense
+ /// [`Offset`]), unlocks, then `notify_waiters()` so any stream parked on the log tail
+ /// wakes and folds the new event into its scope. Returns the assigned offset.
+ ///
+ /// The notify happens *after* the lock is released and the append has committed, so a
+ /// woken stream is guaranteed to see the new event when it re-reads the tail (no woken
+ /// stream can observe a torn or not-yet-committed write).
+ pub fn append(&self, event: Event, recorded_at: Option) -> Result {
+ let offset = {
+ let mut log = self.log.lock().map_err(|_| {
+ ProtocolError::new(ErrorCode::Internal, "the event log lock was poisoned")
+ })?;
+ log.append(event, recorded_at)
+ .map_err(|e| ProtocolError::new(ErrorCode::Internal, e.to_string()))?
+ };
+ self.appended.notify_waiters();
+ Ok(offset)
+ }
+
+ /// A handle to the append-notification primitive, for the change-stream task to park
+ /// on between log reads (see the type docs).
+ pub(crate) fn appended(&self) -> Arc {
+ Arc::clone(&self.appended)
+ }
+
/// Read the whole log into a `Vec` plus the resume [`Cursor`] (the log length
/// at read time). A single lock spans the read so the events and the cursor are
/// consistent with one another.
- fn read(&self) -> Result<(Vec, Cursor), ProtocolError> {
+ pub(crate) fn read(&self) -> Result<(Vec, Cursor), ProtocolError> {
let log = self.log.lock().map_err(|_| {
ProtocolError::new(ErrorCode::Internal, "the event log lock was poisoned")
})?;
@@ -200,6 +247,7 @@ pub fn router(state: AppState) -> Router {
.route("/snapshot/class/{event}/{class}", get(snapshot_class))
.route("/snapshot/heat/{heat}", get(snapshot_heat))
.route("/snapshot/pilot/{event}/{pilot}", get(snapshot_pilot))
+ .route("/stream", get(crate::ws::stream_handler))
.with_state(state)
}
@@ -341,7 +389,7 @@ async fn snapshot_pilot(
}
/// The `at` of an event if it is a lap-gate pass, for deriving a heat's race start.
-fn first_pass_at(event: &Event) -> Option {
+pub(crate) fn first_pass_at(event: &Event) -> Option {
match event {
Event::Pass(p) if p.gate.is_lap_gate() => Some(p.at),
_ => None,
@@ -357,7 +405,7 @@ fn first_pass_at(event: &Event) -> Option {
/// heat's. Passes carry no heat id (they are raw observations), so attribution is by
/// position in the log relative to heat-loop events — the same ordering the engine uses to
/// decide which heat consumes a pass (race-engine.html §2).
-fn heat_window(events: &[Event], heat: &HeatId) -> Vec {
+pub(crate) fn heat_window(events: &[Event], heat: &HeatId) -> Vec {
let mut window = Vec::new();
// `active` tracks whether the cursor is currently inside this heat's span: it opens on
// a heat-loop event for `heat` and closes on a heat-loop event for a *different* heat.
diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs
index bc1220f..4381323 100644
--- a/crates/server/src/lib.rs
+++ b/crates/server/src/lib.rs
@@ -3,15 +3,17 @@
//! by the Cloud). The wire types are defined here once and generated to TypeScript
//! (ts-rs), so clients never hand-write a wire type. See docs/protocol.html.
//!
-//! # What this crate is, as of #41 / #42
+//! # What this crate is, as of #41 / #42 / #43
//!
//! The wire-type surface — the Rust types that *are* the protocol (protocol.html §6:
//! "the contract defined once, in Rust") — plus the **read transport** over them: the
-//! live race-state projection ([`live_state`], #41) and the axum snapshot endpoints
-//! ([`app`], #42). The change stream (#43), auth (#44), and control endpoints (#45)
-//! build on the same [`app::AppState`] and [`app::router`]. Defining the types first
-//! means every one of those issues — and the frontend protocol client (#49) — builds
-//! against a single generated contract that already exists.
+//! live race-state projection ([`live_state`], #41), the axum snapshot endpoints
+//! ([`app`], #42), and the WebSocket change stream ([`ws`], #43) that keeps a client's
+//! scoped projection current after the snapshot. Auth (#44) and control endpoints (#45)
+//! build on the same [`app::AppState`] and [`app::router`] — the control path reusing
+//! [`app::AppState::append`], the one log-write-plus-stream-wakeup the change stream
+//! observes. Defining the types first means every one of those issues — and the frontend
+//! protocol client (#49) — builds against a single generated contract that already exists.
//!
//! Every public wire type derives [`serde`] (its JSON encoding *is* the wire format)
//! and [`ts_rs::TS`] with `#[ts(export, export_to = "bindings/")]`, exactly mirroring
@@ -39,10 +41,11 @@
//! # Deferred (later issues + the doc-reconciliation pass)
//!
//! - **Exact delta encodings.** [`Change::Delta`](stream::Change::Delta) is a
-//! `serde_json::Value` placeholder for now; the precise per-projection delta shapes
-//! (a single appended lap, a heat-state transition, …) are pinned when the change
-//! stream lands (#43). The *structure* — per-projection, delta-vs-fresh — is fixed
-//! here, which is what later issues need.
+//! `serde_json::Value` placeholder; the change stream ([`ws`], #43) emits only
+//! [`Change::FreshValue`](stream::Change::FreshValue) envelopes for now, while wiring
+//! the per-projection delta-vs-fresh *distinction* (§9.2) so the precise delta shapes
+//! (a single appended lap, a heat-state transition, …) can be pinned in #59 without
+//! reshaping the stream or its sequencing.
//! - **Scope addressing grammar.** [`app::router`] pins a concrete REST addressing over
//! the four resources (event/class/heat/pilot, §4); the richer filter grammar (§9.6),
//! and the log-level *class* filter (the log carries no class tag yet), are refined in
@@ -60,6 +63,7 @@ pub mod live_state;
pub mod scope;
pub mod snapshot;
pub mod stream;
+pub mod ws;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
diff --git a/crates/server/src/stream.rs b/crates/server/src/stream.rs
index 684fb07..597f58c 100644
--- a/crates/server/src/stream.rs
+++ b/crates/server/src/stream.rs
@@ -16,6 +16,7 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
+use crate::error::ProtocolError;
use crate::snapshot::{ProjectionBody, ProjectionKind};
/// The public **projection sequence number** (protocol.html §3, §9.5): a single
@@ -95,6 +96,29 @@ pub struct ChangeEnvelope {
pub change: Change,
}
+/// A frame the server sends down the change-stream WebSocket (protocol.html §3).
+///
+/// Almost every frame is a [`ChangeEnvelope`]; the one exception is the distinguished
+/// **re-snapshot-required** signal the server sends when a client resumes from a cursor
+/// older than the bounded retained window (§3 "fall back to re-snapshot"). Modelling
+/// both as one externally-tagged enum means a client reads a single message type off the
+/// socket and branches on the tag, rather than guessing whether a frame is data or a
+/// control signal.
+///
+/// Externally tagged, mapping to a TS discriminated union.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "bindings/")]
+pub enum StreamMessage {
+ /// One ordered, sequenced projection change to apply (the common case).
+ Change(ChangeEnvelope),
+ /// The resume cursor the client presented is older than the server's retained
+ /// window: the gap cannot be replayed, so the client must fetch a fresh snapshot and
+ /// resubscribe from its new cursor (protocol.html §3). The carried
+ /// [`ProtocolError`] always has [`ErrorCode::StaleCursor`](crate::error::ErrorCode::StaleCursor).
+ /// This is the terminal frame of the stream — no envelopes follow it.
+ ReSnapshotRequired(ProtocolError),
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -140,4 +164,30 @@ mod tests {
let back: ChangeEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(env, back);
}
+
+ #[test]
+ fn stream_message_variants_round_trip() {
+ use crate::error::{ErrorCode, ProtocolError};
+
+ let change = StreamMessage::Change(ChangeEnvelope {
+ sequence: Cursor::new(1),
+ projection: ProjectionKind::LiveRaceState,
+ change: Change::FreshValue(ProjectionBody::LiveRaceState(LiveRaceState::default())),
+ });
+ let json = serde_json::to_string(&change).unwrap();
+ assert_eq!(
+ serde_json::from_str::(&json).unwrap(),
+ change
+ );
+
+ let resnap = StreamMessage::ReSnapshotRequired(ProtocolError::new(
+ ErrorCode::StaleCursor,
+ "cursor 3 is below the retained window (oldest replayable offset 8)",
+ ));
+ let json = serde_json::to_string(&resnap).unwrap();
+ assert_eq!(
+ serde_json::from_str::(&json).unwrap(),
+ resnap
+ );
+ }
}
diff --git a/crates/server/src/ws.rs b/crates/server/src/ws.rs
new file mode 100644
index 0000000..514646f
--- /dev/null
+++ b/crates/server/src/ws.rs
@@ -0,0 +1,431 @@
+//! The WebSocket change-stream transport (protocol.html §3, §9.1) — issue #43.
+//!
+//! After fetching a [`Snapshot`](crate::snapshot::Snapshot) over HTTP (#42), a client
+//! opens this single WebSocket (`GET /stream`), sends one
+//! [`SubscribeRequest`](crate::scope::SubscribeRequest), and receives an ordered run of
+//! [`StreamMessage`]s that keep its scoped projection identical to the server's. v0.4 is
+//! **WebSocket-only** (§9.1); the SSE read-only fallback (§9.1 open decision) is not built.
+//!
+//! # The subscribe protocol
+//!
+//! 1. Client → server: exactly one text frame, a JSON [`SubscribeRequest`] — the
+//! [`Scope`](crate::scope::Scope) it wants and an optional `from` resume cursor.
+//! 2. Server → client: a stream of JSON [`StreamMessage`] text frames, each either a
+//! [`ChangeEnvelope`](crate::stream::ChangeEnvelope) or the terminal
+//! [`StreamMessage::ReSnapshotRequired`] signal.
+//!
+//! A malformed or missing subscribe frame closes the socket with a
+//! [`ProtocolError`]-bearing close frame ([`ErrorCode::BadRequest`]); no auth is enforced
+//! here (#44 layers it in front of this handler).
+//!
+//! # Per-stream sequence vs the resume cursor (protocol.html §3, §9.5)
+//!
+//! Two distinct integers, deliberately:
+//!
+//! - The **resume cursor** ([`Cursor`]) a client presents in `from` is a **log offset** —
+//! the same value the [`Snapshot`](crate::snapshot::Snapshot) handed it (the log length
+//! at snapshot time, i.e. the offset of the first event *after* the snapshot). It names a
+//! position in the Director's private append-only log (§1, §9.5) and is what makes resume
+//! work across reconnects: a fresh connection re-presents the last offset it had caught
+//! up to.
+//! - The **per-stream sequence** ([`ChangeEnvelope::sequence`]) is this stream's own
+//! public ordering: a monotonic integer **starting at 1**, incremented by one for every
+//! envelope *this connection* emits (§3 "increases by one per envelope"). It is not the
+//! log offset — one log append can fan out into several projection changes or none the
+//! scope subscribes to, so the sequence advances independently of the offset.
+//!
+//! The mapping between them: as the engine folds the log forward it tracks the log offset
+//! it has consumed up to; whenever the scoped projection's value *changes* it emits one
+//! envelope, assigns it the next per-stream sequence (1, 2, 3, …), and remembers the offset
+//! at which it emitted. A client persists *both* — it renders by sequence order and resumes
+//! by the offset cursor. (The two coincide numerically only by accident; the protocol keeps
+//! them separate so the log offset can stay a private detail while the sequence is the
+//! public contract.)
+//!
+//! # The bounded retained window + re-snapshot (protocol.html §3, §9.3)
+//!
+//! The Director is memory-bounded (§9.3 "keep it simple — one event"): it retains a
+//! window of [`RETAINED_WINDOW`] log offsets behind the current tail. A resume `from`
+//! cursor older than `tail - RETAINED_WINDOW` cannot be replayed, so instead of streaming
+//! a hole the server sends a single [`StreamMessage::ReSnapshotRequired`]
+//! ([`ErrorCode::StaleCursor`]) and closes — the client re-snapshots and resubscribes from
+//! the fresh cursor (§3 "re-snapshot is always correct because projections are
+//! recomputable"). A `from` of `0` (or `None`, a fresh subscribe) is *never* stale: replay
+//! from the start of the log is always in-window by definition.
+//!
+//! # Guarantees (protocol.html §3)
+//!
+//! - **Total order, gap-free.** Envelopes carry strictly increasing per-stream sequences
+//! 1, 2, 3, … with no gaps; a client applying them in order converges to server state.
+//! - **Idempotent, at-least-once with exactly-once effect.** Applying an envelope is keyed
+//! by its sequence, so a redelivery after a flaky reconnect is a no-op. The engine folds
+//! from the log (the source of truth) on every wakeup, so a resumed stream re-derives the
+//! same envelopes for the same offsets — overlap on resume is harmless.
+//!
+//! # Delta vs fresh-value — deferred encodings (protocol.html §9.2)
+//!
+//! Every envelope this engine emits today is a [`Change::FreshValue`]: the whole recomputed
+//! [`ProjectionBody`]. The per-projection delta *encodings* (a single appended lap, a
+//! heat-state transition) are deferred to #59 — [`Change::Delta`] is still an opaque
+//! placeholder. What is wired now is the **distinction** §9.2 fixes: [`ScopeProjection`]
+//! tags each scope as *delta-preferring* (the append-heavy lap-list and live-state scopes,
+//! where #59 will encode incremental deltas) or *fresh-value* (a cheap re-fold like a heat
+//! result after a marshaling correction, which stays a fresh value). The engine records
+//! that preference per envelope so #59 can swap the encoding in without reshaping the
+//! stream or its sequencing.
+
+use axum::extract::State;
+use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
+use axum::response::Response;
+use gridfpv_events::{CompetitorRef, Event};
+use gridfpv_projection::{LapList, lap_list_marshaled};
+
+use crate::app::{AppState, heat_window};
+use crate::error::{ErrorCode, ProtocolError};
+use crate::live_state::live_state;
+use crate::scope::Scope;
+use crate::snapshot::{ProjectionBody, ProjectionKind};
+use crate::stream::{Change, ChangeEnvelope, Cursor, StreamMessage};
+
+/// How many log offsets behind the tail the Director keeps replayable before forcing a
+/// re-snapshot (protocol.html §3, §9.3).
+///
+/// "Keep it simple" (§9.3): a fixed window, not a byte/time budget. A resume cursor older
+/// than `tail - RETAINED_WINDOW` is answered with [`StreamMessage::ReSnapshotRequired`].
+/// The Cloud (later) keeps a much larger window over durable storage; the Director's is
+/// deliberately small and memory-bounded. The value is generous enough that a brief network
+/// blip resumes seamlessly while an offset far in the past is correctly rejected.
+pub const RETAINED_WINDOW: u64 = 256;
+
+/// Whether a projection is append-heavy (so #59 will encode incremental **deltas**) or a
+/// cheap re-fold re-sent whole as a **fresh value** (protocol.html §9.2).
+///
+/// Recorded per envelope so the delta-vs-fresh *distinction* is wired even though every
+/// envelope is a fresh value today (see the module docs). The engine consults it only to
+/// document intent for #59; it does not yet change the encoding.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Encoding {
+ /// Append-heavy: a lap list grows by laps, live-state by passes — #59 sends deltas.
+ DeltaPreferring,
+ /// Cheap to recompute and re-sent whole (a re-folded result / ranking / outcome, e.g.
+ /// after a marshaling correction, §9.2) — stays a fresh value even after #59.
+ FreshValue,
+}
+
+impl Encoding {
+ /// The delta-vs-fresh preference §9.2 fixes for a projection kind: the append-heavy
+ /// live-state and lap-list are delta-preferring; the re-folded result, ranking, and
+ /// event outcome stay fresh values (a single marshaling correction re-folds the whole
+ /// thing, so a delta would be no smaller).
+ fn of(kind: ProjectionKind) -> Self {
+ match kind {
+ ProjectionKind::LiveRaceState | ProjectionKind::LapList => Encoding::DeltaPreferring,
+ ProjectionKind::HeatResult | ProjectionKind::Ranking | ProjectionKind::EventOutcome => {
+ Encoding::FreshValue
+ }
+ }
+ }
+}
+
+/// The projection a [`Scope`] folds to on the change stream, plus the delta-vs-fresh
+/// preference §9.2 fixes for it.
+///
+/// This binds each of the four addressable scopes (§4) to the one projection its change
+/// stream advances, mirroring the snapshot folds in [`crate::app`] so a subscriber and a
+/// snapshot of the same scope agree.
+struct ScopeProjection {
+ kind: ProjectionKind,
+ encoding: Encoding,
+}
+
+impl ScopeProjection {
+ /// The projection + encoding preference a scope's change stream uses.
+ fn of(scope: &Scope) -> Self {
+ // Event / class fold the whole-event live race-state (the class log filter is
+ // still deferred, exactly as the snapshot path notes); the heat scope folds its
+ // own window's live race-state (the tightest, lowest-latency scope); the pilot
+ // scope folds that pilot's lap list across the event.
+ let kind = match scope {
+ Scope::Event { .. } | Scope::Class { .. } | Scope::Heat { .. } => {
+ ProjectionKind::LiveRaceState
+ }
+ Scope::Pilot { .. } => ProjectionKind::LapList,
+ };
+ ScopeProjection {
+ kind,
+ encoding: Encoding::of(kind),
+ }
+ }
+
+ /// Fold the scope's projection over the event prefix `events` (offsets `0..n`).
+ ///
+ /// Pure: the same prefix always yields the same body, so folding `0..n` then `0..n+1`
+ /// and comparing tells the engine whether offset `n` *changed* the projection (and thus
+ /// whether to emit an envelope). Reuses the same fold helpers as the snapshot path so a
+ /// subscriber and a snapshot of the same scope converge to the same value.
+ fn fold(scope: &Scope, events: &[Event]) -> Option {
+ match scope {
+ Scope::Event { .. } | Scope::Class { .. } => {
+ Some(ProjectionBody::LiveRaceState(live_state(events)))
+ }
+ Scope::Heat { heat } => {
+ // Only fold once the heat exists in the log; before that the scope has no
+ // value to stream (the snapshot would 404).
+ let scheduled = events
+ .iter()
+ .any(|e| matches!(e, Event::HeatScheduled { heat: h, .. } if h == heat));
+ if !scheduled {
+ return None;
+ }
+ let window = heat_window(events, heat);
+ Some(ProjectionBody::LiveRaceState(live_state(&window)))
+ }
+ Scope::Pilot { pilot, .. } => {
+ let full =
+ lap_list_marshaled(events.iter().enumerate().map(|(i, e)| (i as u64, e)));
+ let pilot_ref = CompetitorRef(pilot.0.clone());
+ let competitors: Vec<_> = full
+ .competitors
+ .into_iter()
+ .filter(|c| c.competitor.competitor == pilot_ref)
+ .collect();
+ if competitors.is_empty() {
+ return None;
+ }
+ Some(ProjectionBody::LapList(LapList { competitors }))
+ }
+ }
+ }
+}
+
+/// `GET /stream` — upgrade to the change-stream WebSocket (protocol.html §3, §9.1).
+///
+/// The handler upgrades the connection and hands it to [`run_stream`]; auth (#44) and
+/// control command handling (#45) layer on separately — this is the read stream only.
+pub async fn stream_handler(ws: WebSocketUpgrade, State(state): State) -> Response {
+ ws.on_upgrade(move |socket| run_stream(socket, state))
+}
+
+/// Drive one subscribed change stream over an upgraded socket (protocol.html §3).
+///
+/// Reads the one [`SubscribeRequest`], then runs the replay-then-tail loop until the client
+/// disconnects, the cursor is stale, or a send fails.
+async fn run_stream(mut socket: WebSocket, state: AppState) {
+ // 1. The client's single subscribe frame.
+ let request = match recv_subscribe(&mut socket).await {
+ Ok(req) => req,
+ Err(err) => {
+ close_with(&mut socket, err).await;
+ return;
+ }
+ };
+
+ let projection = ScopeProjection::of(&request.scope);
+ // The resume cursor is a log offset (see the module docs); `None` / 0 means "from the
+ // start of the log".
+ let from = request.from.map(|c| c.seq).unwrap_or(0);
+
+ // 2. Stale-cursor check against the bounded retained window.
+ let tail = match state.read() {
+ Ok((_, cursor)) => cursor.seq,
+ Err(err) => {
+ close_with(&mut socket, err).await;
+ return;
+ }
+ };
+ let oldest_replayable = tail.saturating_sub(RETAINED_WINDOW);
+ if from > 0 && from < oldest_replayable {
+ let _ = send_message(
+ &mut socket,
+ &StreamMessage::ReSnapshotRequired(ProtocolError::new(
+ ErrorCode::StaleCursor,
+ format!(
+ "resume cursor {from} is below the retained window \
+ (oldest replayable offset {oldest_replayable})"
+ ),
+ )),
+ )
+ .await;
+ return;
+ }
+
+ // 3. Replay-then-tail. `Engine` folds the log forward from offset `from`, emitting a
+ // fresh-value envelope each time the scoped projection changes, advancing the per-stream
+ // sequence. `applied_offset` is how far into the log it has folded.
+ let mut engine = Engine::new(request.scope, projection, from);
+ let appended = state.appended();
+
+ loop {
+ // Enrol the wakeup in the waiter list *before* reading the log so an append that
+ // lands between the read and the await is not lost. `Notified::enable()` registers
+ // the waiter immediately (rather than on first poll), so a `notify_waiters()` firing
+ // any time after this line wakes this future — even one that fires before `select!`
+ // polls it. Without this, an append in the gap between the read and the await would
+ // wake nobody and the stream would park until the *next* append (a missed update).
+ let mut wake = std::pin::pin!(appended.notified());
+ wake.as_mut().enable();
+
+ // Pull the current log and fold any new events into envelopes.
+ let events = match state.read() {
+ Ok((events, _)) => events,
+ Err(err) => {
+ close_with(&mut socket, err).await;
+ return;
+ }
+ };
+ for message in engine.advance(&events) {
+ if send_message(&mut socket, &message).await.is_err() {
+ return; // client gone
+ }
+ }
+
+ // Park until the next append (the pinned `wake`) or the client drops the socket.
+ tokio::select! {
+ _ = &mut wake => {}
+ // Drain client frames so a close is observed promptly; the client sends
+ // nothing else on the read stream.
+ frame = socket.recv() => {
+ match frame {
+ Some(Ok(Message::Close(_))) | None => return,
+ Some(Ok(_)) => continue, // ignore stray frames, re-read the log
+ Some(Err(_)) => return,
+ }
+ }
+ }
+ }
+}
+
+/// The fold-and-sequence engine for one stream (protocol.html §3).
+///
+/// Holds the scope, its projection/encoding, the per-stream sequence counter, the log
+/// offset it has folded up to, and the last projection value it emitted. [`advance`] folds
+/// any newly-appended events and yields the envelopes whose projection value changed.
+struct Engine {
+ scope: Scope,
+ projection: ScopeProjection,
+ /// The next per-stream sequence to assign (starts at 1, §3).
+ next_seq: u64,
+ /// The log offset the engine has folded through (exclusive upper bound). Starts at the
+ /// resume `from`; advances to the log length as events are consumed.
+ applied_offset: u64,
+ /// The last projection body emitted, to suppress re-emitting an unchanged fold.
+ last_emitted: Option,
+}
+
+impl Engine {
+ fn new(scope: Scope, projection: ScopeProjection, from: u64) -> Self {
+ Self {
+ scope,
+ projection,
+ next_seq: 1,
+ applied_offset: from,
+ // Seed with the projection *at* the resume point so the first envelope reflects
+ // a change *after* `from`, not a re-send of what the snapshot already carried.
+ last_emitted: None,
+ }
+ }
+
+ /// Fold any events appended since the last call into ordered envelopes.
+ ///
+ /// For each new offset `applied_offset..events.len()` it folds the scope over the prefix
+ /// `0..=offset`; when the folded body differs from the last one emitted it produces one
+ /// envelope (fresh value, the next sequence). Walking offset by offset keeps the
+ /// per-stream sequence a faithful "one bump per projection change" and the order total.
+ fn advance(&mut self, events: &[Event]) -> Vec {
+ let mut out = Vec::new();
+ let len = events.len() as u64;
+
+ // On the *first* fold from a resume point > 0, seed `last_emitted` with the
+ // projection value at `from` so we only emit changes strictly after the cursor
+ // (the snapshot already carried the value at `from`). For a fresh subscribe
+ // (`from == 0`) there is nothing prior, so the first non-empty fold is emitted.
+ if self.last_emitted.is_none() && self.applied_offset > 0 {
+ let prefix = &events[..(self.applied_offset as usize).min(events.len())];
+ self.last_emitted = ScopeProjection::fold(&self.scope, prefix);
+ }
+
+ while self.applied_offset < len {
+ self.applied_offset += 1;
+ let prefix = &events[..self.applied_offset as usize];
+ let body = ScopeProjection::fold(&self.scope, prefix);
+ if let Some(body) = body {
+ if self.last_emitted.as_ref() != Some(&body) {
+ out.push(StreamMessage::Change(self.envelope(body.clone())));
+ self.last_emitted = Some(body);
+ }
+ }
+ }
+ out
+ }
+
+ /// Wrap a folded projection body in the next sequenced envelope.
+ ///
+ /// Every envelope is a [`Change::FreshValue`] today; the [`Encoding`] preference is
+ /// recorded for #59 (see the module docs) but does not yet change the wire shape. The
+ /// `kind` is taken from the body so a delta envelope (#59) and a fresh value name the
+ /// same projection.
+ fn envelope(&mut self, body: ProjectionBody) -> ChangeEnvelope {
+ let sequence = Cursor::new(self.next_seq);
+ self.next_seq += 1;
+ let _ = self.projection.encoding; // wired for #59; fresh-value for now
+ let projection = body.kind();
+ debug_assert_eq!(
+ projection, self.projection.kind,
+ "a scope's folded body must match its declared projection kind"
+ );
+ ChangeEnvelope {
+ sequence,
+ projection,
+ change: Change::FreshValue(body),
+ }
+ }
+}
+
+/// Receive and parse the client's single [`SubscribeRequest`] (protocol.html §3, §4).
+///
+/// The first text frame must be a JSON [`SubscribeRequest`]; anything else (a binary
+/// frame, an early close, an unparseable body) is a [`ErrorCode::BadRequest`].
+async fn recv_subscribe(
+ socket: &mut WebSocket,
+) -> Result {
+ match socket.recv().await {
+ Some(Ok(Message::Text(text))) => serde_json::from_str(&text).map_err(|e| {
+ ProtocolError::new(ErrorCode::BadRequest, format!("malformed subscribe: {e}"))
+ }),
+ Some(Ok(Message::Binary(bytes))) => serde_json::from_slice(&bytes).map_err(|e| {
+ ProtocolError::new(ErrorCode::BadRequest, format!("malformed subscribe: {e}"))
+ }),
+ Some(Ok(_)) => Err(ProtocolError::new(
+ ErrorCode::BadRequest,
+ "expected a SubscribeRequest text frame first",
+ )),
+ Some(Err(e)) => Err(ProtocolError::new(
+ ErrorCode::BadRequest,
+ format!("websocket error before subscribe: {e}"),
+ )),
+ None => Err(ProtocolError::new(
+ ErrorCode::BadRequest,
+ "connection closed before a SubscribeRequest was sent",
+ )),
+ }
+}
+
+/// Serialise and send one [`StreamMessage`] as a JSON text frame.
+async fn send_message(socket: &mut WebSocket, message: &StreamMessage) -> Result<(), ()> {
+ let json = serde_json::to_string(message).map_err(|_| ())?;
+ socket
+ .send(Message::Text(json.into()))
+ .await
+ .map_err(|_| ())
+}
+
+/// Close the socket carrying a [`ProtocolError`] in the close frame body (best-effort).
+async fn close_with(socket: &mut WebSocket, err: ProtocolError) {
+ let reason = serde_json::to_string(&err).unwrap_or_else(|_| err.message.clone());
+ let _ = socket
+ .send(Message::Close(Some(axum::extract::ws::CloseFrame {
+ code: axum::extract::ws::close_code::POLICY,
+ reason: reason.into(),
+ })))
+ .await;
+}
diff --git a/crates/server/tests/ws_stream.rs b/crates/server/tests/ws_stream.rs
new file mode 100644
index 0000000..5165670
--- /dev/null
+++ b/crates/server/tests/ws_stream.rs
@@ -0,0 +1,290 @@
+//! WebSocket change-stream integration tests (protocol.html §3) — issue #43.
+//!
+//! These spin the real [`router`] on an ephemeral `127.0.0.1` port with `axum::serve` and
+//! drive it with a real WebSocket client (`tokio-tungstenite`) — no Docker, no mocks of the
+//! transport. Each test:
+//!
+//! 1. builds an [`AppState`] over an [`InMemoryLog`], keeping a clone to append through;
+//! 2. serves [`router`] in a background task on an OS-assigned port;
+//! 3. connects, sends a [`SubscribeRequest`], and appends events via
+//! [`AppState::append`] (the same write path the control endpoint #45 will use);
+//! 4. asserts the client receives ordered, gap-free [`ChangeEnvelope`]s converging to the
+//! server's folded state.
+//!
+//! Determinism: appends are explicit and the client awaits each expected frame, so there is
+//! no reliance on timing — a short `timeout` only guards against a hang on a missing frame.
+
+use std::time::Duration;
+
+use futures_util::{SinkExt, StreamExt};
+use gridfpv_events::{CompetitorRef, Event, HeatId, HeatTransition};
+use gridfpv_server::app::{AppState, router};
+use gridfpv_server::scope::{EventId, Scope, SubscribeRequest};
+use gridfpv_server::snapshot::{HeatPhase, ProjectionBody};
+use gridfpv_server::stream::{Change, Cursor, StreamMessage};
+use gridfpv_storage::InMemoryLog;
+use tokio::net::TcpStream;
+use tokio_tungstenite::tungstenite::Message;
+use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
+
+type Ws = WebSocketStream>;
+
+/// Serve `router(state)` on an ephemeral port; return the `ws://…/stream` URL and the
+/// server task's join handle (dropped at test end, which aborts the task).
+async fn serve(state: AppState) -> (String, tokio::task::JoinHandle<()>) {
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap();
+ let app = router(state);
+ let handle = tokio::spawn(async move {
+ axum::serve(listener, app).await.unwrap();
+ });
+ (format!("ws://{addr}/stream"), handle)
+}
+
+/// Connect to the stream endpoint and send a subscribe frame.
+async fn subscribe(url: &str, request: &SubscribeRequest) -> Ws {
+ let (mut ws, _) = connect_async(url).await.unwrap();
+ let json = serde_json::to_string(request).unwrap();
+ ws.send(Message::text(json)).await.unwrap();
+ ws
+}
+
+/// Await the next [`StreamMessage`] text frame (with a timeout so a missing frame fails the
+/// test rather than hanging).
+async fn next_message(ws: &mut Ws) -> StreamMessage {
+ let frame = tokio::time::timeout(Duration::from_secs(5), ws.next())
+ .await
+ .expect("timed out waiting for a stream frame")
+ .expect("stream closed unexpectedly")
+ .expect("websocket error");
+ match frame {
+ Message::Text(text) => serde_json::from_str(&text).expect("parse StreamMessage"),
+ Message::Close(frame) => panic!("server closed the stream: {frame:?}"),
+ other => panic!("expected a text frame, got {other:?}"),
+ }
+}
+
+/// The `LiveRaceState` body of a `Change` stream message, asserting it is a fresh value
+/// (the only encoding emitted today, §9.2 deferral).
+fn live_body(message: &StreamMessage) -> &gridfpv_server::snapshot::LiveRaceState {
+ match message {
+ StreamMessage::Change(env) => match &env.change {
+ Change::FreshValue(ProjectionBody::LiveRaceState(ls)) => ls,
+ other => panic!("expected a fresh-value live-state, got {other:?}"),
+ },
+ other => panic!("expected a Change, got {other:?}"),
+ }
+}
+
+fn heat_scheduled(id: &str, lineup: &[&str]) -> Event {
+ Event::HeatScheduled {
+ heat: HeatId(id.into()),
+ lineup: lineup.iter().map(|c| CompetitorRef((*c).into())).collect(),
+ }
+}
+
+fn heat_changed(id: &str, transition: HeatTransition) -> Event {
+ Event::HeatStateChanged {
+ heat: HeatId(id.into()),
+ transition,
+ }
+}
+
+fn event_scope() -> Scope {
+ Scope::Event {
+ event: EventId("spring-cup".into()),
+ }
+}
+
+/// Subscribe fresh (from the start), append events, and assert the client receives ordered,
+/// gap-free envelopes whose final state matches the server's fold.
+#[tokio::test]
+async fn streams_ordered_envelopes_from_a_fresh_subscribe() {
+ let state = AppState::new(InMemoryLog::default());
+ let (url, _server) = serve(state.clone()).await;
+
+ let mut ws = subscribe(
+ &url,
+ &SubscribeRequest {
+ scope: event_scope(),
+ from: None,
+ },
+ )
+ .await;
+
+ // Append a heat scheduling then drive it through the loop. Each append that *changes*
+ // the live-state fold yields one envelope.
+ state
+ .append(heat_scheduled("q-1", &["A", "B"]), None)
+ .unwrap();
+ let m1 = next_message(&mut ws).await;
+ assert_eq!(seq(&m1), 1, "first envelope is per-stream sequence 1");
+ assert_eq!(live_body(&m1).current_heat, Some(HeatId("q-1".into())));
+ assert_eq!(live_body(&m1).phase, HeatPhase::Scheduled);
+
+ state
+ .append(heat_changed("q-1", HeatTransition::Staged), None)
+ .unwrap();
+ let m2 = next_message(&mut ws).await;
+ assert_eq!(seq(&m2), 2, "sequence increments by one, gap-free");
+ assert_eq!(live_body(&m2).phase, HeatPhase::Staged);
+
+ state
+ .append(heat_changed("q-1", HeatTransition::Armed), None)
+ .unwrap();
+ let m3 = next_message(&mut ws).await;
+ assert_eq!(seq(&m3), 3);
+ assert_eq!(live_body(&m3).phase, HeatPhase::Armed);
+
+ state
+ .append(heat_changed("q-1", HeatTransition::Running), None)
+ .unwrap();
+ let m4 = next_message(&mut ws).await;
+ assert_eq!(seq(&m4), 4);
+ assert_eq!(live_body(&m4).phase, HeatPhase::Running);
+}
+
+/// Resume from a mid-stream cursor: a second connection presenting an in-window log offset
+/// receives only the changes *after* that offset, with its own per-stream sequence restarting
+/// at 1.
+#[tokio::test]
+async fn resume_from_a_mid_cursor_replays_only_the_tail() {
+ let state = AppState::new(InMemoryLog::default());
+ let (url, _server) = serve(state.clone()).await;
+
+ // Pre-load three events (offsets 0,1,2 → log length 3).
+ state
+ .append(heat_scheduled("q-1", &["A", "B"]), None)
+ .unwrap();
+ state
+ .append(heat_changed("q-1", HeatTransition::Staged), None)
+ .unwrap();
+ state
+ .append(heat_changed("q-1", HeatTransition::Armed), None)
+ .unwrap();
+
+ // Resume from offset 2 (the snapshot cursor a client would have after the Staged
+ // change): it must NOT replay the earlier two, only fold offset 2 onward.
+ let mut ws = subscribe(
+ &url,
+ &SubscribeRequest {
+ scope: event_scope(),
+ from: Some(Cursor::new(2)),
+ },
+ )
+ .await;
+
+ // Offset 2 (the Armed change) is the first event after the cursor → first envelope.
+ let m1 = next_message(&mut ws).await;
+ assert_eq!(seq(&m1), 1, "a resumed stream's own sequence restarts at 1");
+ assert_eq!(live_body(&m1).phase, HeatPhase::Armed);
+
+ // A further append continues the resumed stream in order.
+ state
+ .append(heat_changed("q-1", HeatTransition::Running), None)
+ .unwrap();
+ let m2 = next_message(&mut ws).await;
+ assert_eq!(seq(&m2), 2);
+ assert_eq!(live_body(&m2).phase, HeatPhase::Running);
+}
+
+/// A resume cursor older than the bounded retained window gets the re-snapshot-required
+/// signal instead of a replay (protocol.html §3, §9.3).
+#[tokio::test]
+async fn too_old_cursor_requires_re_snapshot() {
+ let state = AppState::new(InMemoryLog::default());
+ let (url, _server) = serve(state.clone()).await;
+
+ // Push the log tail well past the retained window so a low cursor is out of range.
+ let tail = gridfpv_server::ws::RETAINED_WINDOW + 50;
+ for _ in 0..tail {
+ state.append(heat_scheduled("q-1", &["A"]), None).unwrap();
+ }
+
+ // Resume from offset 1 — far below `tail - RETAINED_WINDOW`.
+ let mut ws = subscribe(
+ &url,
+ &SubscribeRequest {
+ scope: event_scope(),
+ from: Some(Cursor::new(1)),
+ },
+ )
+ .await;
+
+ match next_message(&mut ws).await {
+ StreamMessage::ReSnapshotRequired(err) => {
+ assert_eq!(err.code, gridfpv_server::error::ErrorCode::StaleCursor);
+ }
+ other => panic!("expected ReSnapshotRequired, got {other:?}"),
+ }
+}
+
+/// A change that does not alter the scoped projection emits no envelope (the engine emits
+/// one envelope per *change*, keeping the per-stream sequence a faithful change count).
+#[tokio::test]
+async fn unchanged_fold_emits_no_envelope() {
+ let state = AppState::new(InMemoryLog::default());
+ let (url, _server) = serve(state.clone()).await;
+
+ let mut ws = subscribe(
+ &url,
+ &SubscribeRequest {
+ scope: event_scope(),
+ from: None,
+ },
+ )
+ .await;
+
+ state
+ .append(heat_scheduled("q-1", &["A", "B"]), None)
+ .unwrap();
+ let m1 = next_message(&mut ws).await;
+ assert_eq!(seq(&m1), 1);
+ assert_eq!(live_body(&m1).phase, HeatPhase::Scheduled);
+
+ // Re-scheduling the same heat with the same lineup folds to the same live state → no
+ // new envelope. A following Running transition *does* change it and must arrive as
+ // sequence 2 (proving sequence 2 was not silently consumed by the no-op append).
+ state
+ .append(heat_scheduled("q-1", &["A", "B"]), None)
+ .unwrap();
+ state
+ .append(heat_changed("q-1", HeatTransition::Running), None)
+ .unwrap();
+ let m2 = next_message(&mut ws).await;
+ assert_eq!(seq(&m2), 2, "the no-op append did not consume a sequence");
+ assert_eq!(live_body(&m2).phase, HeatPhase::Running);
+}
+
+/// A malformed first frame closes the socket with a BadRequest close (no panic, no hang).
+#[tokio::test]
+async fn malformed_subscribe_closes_the_socket() {
+ let state = AppState::new(InMemoryLog::default());
+ let (url, _server) = serve(state).await;
+
+ let (mut ws, _) = connect_async(&url).await.unwrap();
+ ws.send(Message::text("not a subscribe request"))
+ .await
+ .unwrap();
+
+ // The server replies with a close frame and ends the stream.
+ let closed = tokio::time::timeout(Duration::from_secs(5), async {
+ while let Some(frame) = ws.next().await {
+ if matches!(frame, Ok(Message::Close(_))) || frame.is_err() {
+ return true;
+ }
+ }
+ true // stream ended
+ })
+ .await
+ .expect("timed out waiting for the socket to close");
+ assert!(closed);
+}
+
+/// The per-stream sequence of a `Change` message.
+fn seq(message: &StreamMessage) -> u64 {
+ match message {
+ StreamMessage::Change(env) => env.sequence.seq,
+ other => panic!("expected a Change, got {other:?}"),
+ }
+}
From 2c711541b58558487a9755473b6c68a029817db9 Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 04:10:07 +0000
Subject: [PATCH 007/362] Build the shared Svelte component library v1 (#50)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Flesh out @gridfpv/components from its placeholder set into the v1
"one component, three presentations" library (docs/clients.html §3, §5):
every widget takes typed projection data from @gridfpv/types as props
(never a hand-written wire shape) and themes off a single design-token
layer, so the RD console, spectator PWA, and OBS overlays compose the
same components with per-context token overrides.
Components (props → source type):
- Leaderboard — result: HeatResult (places: Placement[])
- StandingsTable — entries: RankEntry[]
- BracketTree — bracket: Bracket (local view-model; the wire has no
bracket projection yet — derived from heats/results,
documented in bracket.ts; swap for a generated type later)
- HeatSheet — state: LiveRaceState (active_pilots + PilotProgress +
running_order + phase)
- RaceClock — elapsedMs/remainingMs (pure presentational)
- PilotCard — progress: PilotProgress
Design tokens (tokens.css): one token set (color, spacing, type scale,
radii) as CSS custom properties, with .gridfpv-overlay and .gridfpv-dense
context classes re-pointing the same tokens; exported as
@gridfpv/components/tokens.css. a11y: semantic tables/lists, roles,
accessible labels; responsive.
Shared pure formatters (format.ts: formatClock/formatMicros/formatMetric/
medalFor) keep "how a lap time reads" in one place for components + tests.
Tests: vitest + @testing-library/svelte render each component from typed
fixtures and assert the key fields; 20 component tests + 27 total across
the frontend. The rd-console App.svelte becomes a small playground that
composes all six widgets (the heavy console UI is #51+).
Also adds PilotProgress to the @gridfpv/types barrel (it existed in
bindings/ but was unexported).
Confined to frontend/; no Rust/xtask/bindings/Cargo changes.
npm run build / check / lint / test all green.
Part of #50.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
frontend/apps/rd-console/src/App.svelte | 125 +-
frontend/apps/rd-console/src/main.ts | 3 +
frontend/package-lock.json | 1088 ++++++++++++++++-
frontend/packages/components/package.json | 12 +-
.../components/src/BracketTree.svelte | 89 ++
.../packages/components/src/HeatSheet.svelte | 115 ++
.../components/src/Leaderboard.svelte | 110 +-
.../packages/components/src/PilotCard.svelte | 96 ++
.../packages/components/src/RaceClock.svelte | 46 +-
.../components/src/StandingsTable.svelte | 99 ++
frontend/packages/components/src/bracket.ts | 52 +
frontend/packages/components/src/format.ts | 54 +
frontend/packages/components/src/index.ts | 26 +-
frontend/packages/components/src/tokens.css | 101 ++
.../components/tests/BracketTree.test.ts | 28 +
.../components/tests/HeatSheet.test.ts | 30 +
.../components/tests/Leaderboard.test.ts | 36 +
.../components/tests/PilotCard.test.ts | 28 +
.../components/tests/RaceClock.test.ts | 21 +
.../components/tests/StandingsTable.test.ts | 23 +
.../packages/components/tests/fixtures.ts | 90 ++
.../packages/components/tests/format.test.ts | 47 +
.../packages/components/tsconfig.test.json | 11 +
frontend/packages/components/vitest.config.ts | 28 +
frontend/packages/components/vitest.setup.ts | 3 +
frontend/packages/types/src/generated.ts | 1 +
26 files changed, 2277 insertions(+), 85 deletions(-)
create mode 100644 frontend/packages/components/src/BracketTree.svelte
create mode 100644 frontend/packages/components/src/HeatSheet.svelte
create mode 100644 frontend/packages/components/src/PilotCard.svelte
create mode 100644 frontend/packages/components/src/StandingsTable.svelte
create mode 100644 frontend/packages/components/src/bracket.ts
create mode 100644 frontend/packages/components/src/format.ts
create mode 100644 frontend/packages/components/src/tokens.css
create mode 100644 frontend/packages/components/tests/BracketTree.test.ts
create mode 100644 frontend/packages/components/tests/HeatSheet.test.ts
create mode 100644 frontend/packages/components/tests/Leaderboard.test.ts
create mode 100644 frontend/packages/components/tests/PilotCard.test.ts
create mode 100644 frontend/packages/components/tests/RaceClock.test.ts
create mode 100644 frontend/packages/components/tests/StandingsTable.test.ts
create mode 100644 frontend/packages/components/tests/fixtures.ts
create mode 100644 frontend/packages/components/tests/format.test.ts
create mode 100644 frontend/packages/components/tsconfig.test.json
create mode 100644 frontend/packages/components/vitest.config.ts
create mode 100644 frontend/packages/components/vitest.setup.ts
diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte
index a233fc8..9a9bd75 100644
--- a/frontend/apps/rd-console/src/App.svelte
+++ b/frontend/apps/rd-console/src/App.svelte
@@ -1,16 +1,23 @@
-
+
GridFPV — RD console
-
Scaffold shell. Connected to {client.baseUrl} (stub).
+
+ Component library playground. Connected to {client.baseUrl} (stub).
+
+
+
+ {#each entries as entry (entry.competitor)}
+ {@const medal = medalFor(entry.position)}
+
+
{entry.position}
+
{entry.competitor}
+
+ {/each}
+
+
+
+
diff --git a/frontend/packages/components/src/bracket.ts b/frontend/packages/components/src/bracket.ts
new file mode 100644
index 0000000..47c08fb
--- /dev/null
+++ b/frontend/packages/components/src/bracket.ts
@@ -0,0 +1,52 @@
+/**
+ * Bracket view-model — a small, presentation-only shape for `BracketTree`.
+ *
+ * ── Why a view-model lives here ──────────────────────────────────────────────
+ * The protocol wire (the ts-rs `bindings/`) does NOT yet expose a single-elim
+ * bracket projection: it has `HeatId`, `HeatResult`/`Placement`, `CompletedHeat`,
+ * and `CompetitorRef`, but no "rounds → heats → winners" tree. Rather than
+ * hand-write a wire type (forbidden — `@gridfpv/types` is the only seam to the
+ * generated contract), `BracketTree` takes this explicit **view-model** that a
+ * caller derives from heats + their results. When the server grows a real
+ * bracket projection, this type is replaced by the generated one and the
+ * component's prop simply re-points at `@gridfpv/types`.
+ *
+ * The shape intentionally mirrors the wire vocabulary it is built from:
+ * - slots carry a `CompetitorRef` (the source-local handle, as `Placement`
+ * and `RankEntry` do) plus an optional human `label`;
+ * - a match's `winner` is a `CompetitorRef`, matching what a `HeatResult`'s
+ * top `Placement` yields.
+ */
+
+import type { CompetitorRef, HeatId } from '@gridfpv/types';
+
+/** One competitor's seat in a bracket match. */
+export interface BracketSlot {
+ /** Source-local competitor handle (as carried on the wire), if seated. */
+ competitor?: CompetitorRef;
+ /** Optional display label; falls back to `competitor` when absent. */
+ label?: string;
+ /** Marks the slot that advanced from this match (the heat's winner). */
+ winner?: boolean;
+}
+
+/** A single heat in the bracket (two or more seats racing for advancement). */
+export interface BracketMatch {
+ /** The heat this match corresponds to in the log, if scheduled. */
+ heat?: HeatId;
+ /** The seats in this match, in lineup order. */
+ slots: BracketSlot[];
+}
+
+/** One round of the bracket (e.g. quarterfinals), earliest round first. */
+export interface BracketRound {
+ /** Human round name (e.g. `'Quarterfinals'`, `'Final'`). */
+ name: string;
+ /** The matches in this round, top to bottom. */
+ matches: BracketMatch[];
+}
+
+/** A single-elimination bracket: rounds in order, last round is the final. */
+export interface Bracket {
+ rounds: BracketRound[];
+}
diff --git a/frontend/packages/components/src/format.ts b/frontend/packages/components/src/format.ts
new file mode 100644
index 0000000..e2fc2b6
--- /dev/null
+++ b/frontend/packages/components/src/format.ts
@@ -0,0 +1,54 @@
+/**
+ * Pure presentational formatters shared across the component library.
+ *
+ * Durations on the wire are microseconds (`bigint`, source clock — see
+ * `@bindings/SourceTime` / `Lap.duration_micros`); these turn them into the
+ * compact strings the widgets show. Kept framework-pure so components and tests
+ * share one source of truth for "how a lap time reads".
+ */
+
+import type { Metric } from '@gridfpv/types';
+
+/** Format a clock duration in **milliseconds** as `M:SS.mmm` (e.g. `1:23.456`). */
+export function formatClock(ms: number): string {
+ const totalMs = Math.max(0, Math.floor(ms));
+ const minutes = Math.floor(totalMs / 60000);
+ const seconds = Math.floor((totalMs % 60000) / 1000);
+ const millis = totalMs % 1000;
+ return `${minutes}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
+}
+
+/**
+ * Format a lap/split duration given in **microseconds** (the wire unit) as
+ * `S.mmm` seconds, or minutes:seconds when ≥ 60s. `null`/`undefined` → `'—'`.
+ */
+export function formatMicros(micros: bigint | null | undefined): string {
+ if (micros === null || micros === undefined) return '—';
+ const totalMs = Number(micros / 1000n);
+ if (totalMs >= 60000) return formatClock(totalMs);
+ const seconds = Math.floor(totalMs / 1000);
+ const millis = totalMs % 1000;
+ return `${seconds}.${String(millis).padStart(3, '0')}`;
+}
+
+/**
+ * Render a scoring [`Metric`] for display — the condition-specific deciding
+ * value carried on a `Placement`. Durations become `S.mmm`; the time-of-event
+ * metrics (`LastLapAt`, `ReachedAt`) carry a source timestamp we surface as a
+ * coarse marker rather than inventing a wall-clock format here.
+ */
+export function formatMetric(metric: Metric): string {
+ if ('BestLapMicros' in metric) return formatMicros(metric.BestLapMicros);
+ if ('BestConsecutiveMicros' in metric) return formatMicros(metric.BestConsecutiveMicros);
+ if ('LastLapAt' in metric) return metric.LastLapAt === null ? '—' : 'banked';
+ if ('ReachedAt' in metric) return metric.ReachedAt === null ? '—' : 'reached';
+ return '—';
+}
+
+/** The medal token name for a 1-based finishing position, or `null` past 3rd. */
+export function medalFor(position: number): 'gold' | 'silver' | 'bronze' | null {
+ if (position === 1) return 'gold';
+ if (position === 2) return 'silver';
+ if (position === 3) return 'bronze';
+ return null;
+}
diff --git a/frontend/packages/components/src/index.ts b/frontend/packages/components/src/index.ts
index 7ebb4e4..21031e0 100644
--- a/frontend/packages/components/src/index.ts
+++ b/frontend/packages/components/src/index.ts
@@ -2,9 +2,29 @@
* @gridfpv/components — shared GridFPV Svelte 5 component library.
*
* Race-domain widgets built once and themed per surface (RD console, spectator
- * PWA, OBS overlays), per docs/clients.html §3. Placeholder set for now; later
- * issues flesh out the real leaderboard, bracket tree, heat sheet, pilot card,
- * standings table, etc.
+ * PWA, OBS overlays), per docs/clients.html §3 ("one component, three
+ * presentations") and §5 (one token set, three contexts). Every component takes
+ * **typed projection data from `@gridfpv/types`** as props — never a hand-written
+ * wire shape — and styles itself only through the design tokens in `tokens.css`,
+ * so a surface re-themes the whole set by overriding CSS custom properties.
+ *
+ * The library is framework-pure: it depends on `@gridfpv/types` (types only) and
+ * Svelte, never on `@gridfpv/protocol-client`. Apps wire data in.
+ *
+ * Design tokens ship as a stylesheet a surface imports once:
+ * import '@gridfpv/components/tokens.css';
*/
+
+// Components
export { default as Leaderboard } from './Leaderboard.svelte';
+export { default as StandingsTable } from './StandingsTable.svelte';
+export { default as BracketTree } from './BracketTree.svelte';
+export { default as HeatSheet } from './HeatSheet.svelte';
export { default as RaceClock } from './RaceClock.svelte';
+export { default as PilotCard } from './PilotCard.svelte';
+
+// Presentational helpers (pure, framework-agnostic)
+export { formatClock, formatMicros, formatMetric, medalFor } from './format.js';
+
+// View-model types
+export type { Bracket, BracketRound, BracketMatch, BracketSlot } from './bracket.js';
diff --git a/frontend/packages/components/src/tokens.css b/frontend/packages/components/src/tokens.css
new file mode 100644
index 0000000..a9d19ca
--- /dev/null
+++ b/frontend/packages/components/src/tokens.css
@@ -0,0 +1,101 @@
+/**
+ * GridFPV design tokens — one token set, three contexts (docs/clients.html §5).
+ *
+ * Every component styles itself only through these CSS custom properties, so a
+ * surface re-themes the whole library by overriding tokens on a wrapping element
+ * (or `:root`) — no per-component CSS overrides. The defaults below are the
+ * "RD console" baseline (readable on a light surface); the `.gridfpv-overlay`
+ * and `.gridfpv-dense` context classes below re-point the same tokens for the
+ * OBS overlay (transparent, large, high-contrast) and dense-table contexts.
+ *
+ * Token groups:
+ * --gf-color-* palette (surface, text, accent, position medals, state)
+ * --gf-space-* spacing scale (4px base)
+ * --gf-font-* type family + size scale + weights
+ * --gf-radius-* corner radii
+ *
+ * Import this once per surface (e.g. in an app's entry CSS):
+ * import '@gridfpv/components/tokens.css';
+ */
+
+:where(.gridfpv-root),
+:root {
+ /* ── Palette ─────────────────────────────────────────────────────────── */
+ --gf-color-surface: #ffffff;
+ --gf-color-surface-alt: #f4f5f7;
+ --gf-color-border: #d9dce1;
+ --gf-color-text: #1a1d21;
+ --gf-color-text-muted: #6b7280;
+ --gf-color-accent: #2563eb;
+ --gf-color-accent-contrast: #ffffff;
+
+ /* Position / medal colors, used by leaderboards + standings. */
+ --gf-color-gold: #d4af37;
+ --gf-color-silver: #9aa0a6;
+ --gf-color-bronze: #b06a2c;
+
+ /* State colors (live phase, win/leader, penalty/DQ). */
+ --gf-color-live: #16a34a;
+ --gf-color-leader: #2563eb;
+ --gf-color-warn: #b45309;
+ --gf-color-danger: #dc2626;
+
+ /* ── Spacing scale (4px base) ────────────────────────────────────────── */
+ --gf-space-1: 0.25rem;
+ --gf-space-2: 0.5rem;
+ --gf-space-3: 0.75rem;
+ --gf-space-4: 1rem;
+ --gf-space-6: 1.5rem;
+ --gf-space-8: 2rem;
+
+ /* ── Typography ──────────────────────────────────────────────────────── */
+ --gf-font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
+ --gf-font-mono: ui-monospace, 'SF Mono', 'Roboto Mono', monospace;
+
+ --gf-font-size-xs: 0.75rem;
+ --gf-font-size-sm: 0.875rem;
+ --gf-font-size-md: 1rem;
+ --gf-font-size-lg: 1.25rem;
+ --gf-font-size-xl: 1.75rem;
+ --gf-font-size-2xl: 2.5rem;
+
+ --gf-font-weight-normal: 400;
+ --gf-font-weight-medium: 600;
+ --gf-font-weight-bold: 700;
+
+ /* ── Radii ───────────────────────────────────────────────────────────── */
+ --gf-radius-sm: 4px;
+ --gf-radius-md: 8px;
+}
+
+/**
+ * Overlay context: transparent background, oversized + high-contrast text for a
+ * broadcast keyer. Apply `class="gridfpv-overlay"` on a wrapping element.
+ */
+:where(.gridfpv-overlay) {
+ --gf-color-surface: transparent;
+ --gf-color-surface-alt: rgba(0, 0, 0, 0.35);
+ --gf-color-border: rgba(255, 255, 255, 0.25);
+ --gf-color-text: #ffffff;
+ --gf-color-text-muted: rgba(255, 255, 255, 0.7);
+ --gf-color-accent: #38bdf8;
+
+ --gf-font-size-sm: 1rem;
+ --gf-font-size-md: 1.25rem;
+ --gf-font-size-lg: 1.75rem;
+ --gf-font-size-xl: 2.5rem;
+ --gf-font-size-2xl: 3.5rem;
+}
+
+/**
+ * Dense context: tighter spacing + smaller type for the data-dense RD console
+ * tables. Apply `class="gridfpv-dense"` on a wrapping element.
+ */
+:where(.gridfpv-dense) {
+ --gf-space-2: 0.375rem;
+ --gf-space-3: 0.5rem;
+ --gf-space-4: 0.625rem;
+
+ --gf-font-size-sm: 0.8125rem;
+ --gf-font-size-md: 0.875rem;
+}
diff --git a/frontend/packages/components/tests/BracketTree.test.ts b/frontend/packages/components/tests/BracketTree.test.ts
new file mode 100644
index 0000000..0e509e2
--- /dev/null
+++ b/frontend/packages/components/tests/BracketTree.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import BracketTree from '../src/BracketTree.svelte';
+import { bracket } from './fixtures.js';
+
+describe('BracketTree', () => {
+ it('renders each round, its matches, and the seated competitors', () => {
+ const { container } = render(BracketTree, { bracket });
+
+ expect(screen.getByText('Semifinals')).toBeInTheDocument();
+ expect(screen.getByText('Final')).toBeInTheDocument();
+
+ // Competitors across the rounds.
+ expect(screen.getByText('DANA')).toBeInTheDocument();
+ expect(screen.getAllByText('ALICE').length).toBeGreaterThan(0);
+
+ // Three matches total (2 semis + 1 final).
+ expect(container.querySelectorAll('.match').length).toBe(3);
+ });
+
+ it('marks the advancing seat in each match', () => {
+ const { container } = render(BracketTree, { bracket });
+ const winners = container.querySelectorAll('.slot.winner');
+ // One winner per match.
+ expect(winners.length).toBe(3);
+ winners.forEach((w) => expect(w).toHaveAttribute('aria-selected', 'true'));
+ });
+});
diff --git a/frontend/packages/components/tests/HeatSheet.test.ts b/frontend/packages/components/tests/HeatSheet.test.ts
new file mode 100644
index 0000000..4138aa2
--- /dev/null
+++ b/frontend/packages/components/tests/HeatSheet.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import HeatSheet from '../src/HeatSheet.svelte';
+import { liveState } from './fixtures.js';
+
+describe('HeatSheet', () => {
+ it('renders the heat id, phase, and each pilot with live laps', () => {
+ render(HeatSheet, { state: liveState });
+
+ expect(screen.getByText('heat-1')).toBeInTheDocument();
+ expect(screen.getByText('Running')).toBeInTheDocument();
+
+ expect(screen.getByText('ALICE')).toBeInTheDocument();
+ expect(screen.getByText('BOB')).toBeInTheDocument();
+ expect(screen.getByText('CARMEN')).toBeInTheDocument();
+
+ // ALICE leads the running order with 3 laps.
+ const aliceRow = screen.getByText('ALICE').closest('li');
+ expect(aliceRow!).toHaveTextContent('3 laps');
+
+ // CARMEN has no completed last lap → em dash.
+ const carmenRow = screen.getByText('CARMEN').closest('li');
+ expect(carmenRow!).toHaveTextContent('—');
+ });
+
+ it('maps competitor refs to display names when provided', () => {
+ render(HeatSheet, { state: liveState, names: { ALICE: 'Alice A.' } });
+ expect(screen.getByText('Alice A.')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/packages/components/tests/Leaderboard.test.ts b/frontend/packages/components/tests/Leaderboard.test.ts
new file mode 100644
index 0000000..176a76d
--- /dev/null
+++ b/frontend/packages/components/tests/Leaderboard.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import Leaderboard from '../src/Leaderboard.svelte';
+import { heatResult } from './fixtures.js';
+
+describe('Leaderboard', () => {
+ it('renders every competitor with position, laps, and metric', () => {
+ render(Leaderboard, { result: heatResult, metricLabel: 'Best lap' });
+
+ // All three pilots present.
+ expect(screen.getByText('ALICE')).toBeInTheDocument();
+ expect(screen.getByText('BOB')).toBeInTheDocument();
+ expect(screen.getByText('CARMEN')).toBeInTheDocument();
+
+ // The custom metric column heading.
+ expect(screen.getByText('Best lap')).toBeInTheDocument();
+
+ // ALICE's row: position 1, 3 laps, best lap 41.250.
+ const aliceRow = screen.getByText('ALICE').closest('tr');
+ expect(aliceRow).not.toBeNull();
+ expect(aliceRow!).toHaveTextContent('1');
+ expect(aliceRow!).toHaveTextContent('3');
+ expect(aliceRow!).toHaveTextContent('41.250');
+
+ // CARMEN has a null metric → em dash.
+ const carmenRow = screen.getByText('CARMEN').closest('tr');
+ expect(carmenRow!).toHaveTextContent('—');
+ });
+
+ it('marks the podium positions with medal data attributes', () => {
+ const { container } = render(Leaderboard, { result: heatResult });
+ expect(container.querySelector('tr[data-medal="gold"]')).not.toBeNull();
+ expect(container.querySelector('tr[data-medal="silver"]')).not.toBeNull();
+ expect(container.querySelector('tr[data-medal="bronze"]')).not.toBeNull();
+ });
+});
diff --git a/frontend/packages/components/tests/PilotCard.test.ts b/frontend/packages/components/tests/PilotCard.test.ts
new file mode 100644
index 0000000..c0cbf6b
--- /dev/null
+++ b/frontend/packages/components/tests/PilotCard.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import PilotCard from '../src/PilotCard.svelte';
+import { pilotProgress } from './fixtures.js';
+
+describe('PilotCard', () => {
+ it('shows identity, lap count, and last lap from PilotProgress', () => {
+ render(PilotCard, { progress: pilotProgress, position: 1 });
+
+ expect(screen.getByText('ALICE')).toBeInTheDocument();
+ expect(screen.getByText('2')).toBeInTheDocument(); // laps_completed
+ expect(screen.getByText('41.250')).toBeInTheDocument(); // last lap (µs → s)
+ expect(screen.getByLabelText('Position 1')).toBeInTheDocument();
+ });
+
+ it('prefers an explicit display name over the source-local ref', () => {
+ render(PilotCard, { progress: pilotProgress, name: 'Alice A.' });
+ expect(screen.getByText('Alice A.')).toBeInTheDocument();
+ expect(screen.queryByText('ALICE')).toBeNull();
+ });
+
+ it('renders an em dash when no lap has been completed', () => {
+ render(PilotCard, {
+ progress: { competitor: 'NEW', laps_completed: 0, last_lap_micros: undefined }
+ });
+ expect(screen.getByText('—')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/packages/components/tests/RaceClock.test.ts b/frontend/packages/components/tests/RaceClock.test.ts
new file mode 100644
index 0000000..ca2a377
--- /dev/null
+++ b/frontend/packages/components/tests/RaceClock.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import RaceClock from '../src/RaceClock.svelte';
+
+describe('RaceClock', () => {
+ it('formats elapsed milliseconds as M:SS.mmm', () => {
+ render(RaceClock, { elapsedMs: 83_456 });
+ expect(screen.getByText('1:23.456')).toBeInTheDocument();
+ });
+
+ it('renders a countdown and marks remaining mode when given remainingMs', () => {
+ const { container } = render(RaceClock, { remainingMs: 5_000 });
+ expect(screen.getByText('0:05.000')).toBeInTheDocument();
+ expect(container.querySelector('[data-mode="remaining"]')).not.toBeNull();
+ });
+
+ it('exposes an accessible timer label', () => {
+ render(RaceClock, { elapsedMs: 0, label: 'Lap timer' });
+ expect(screen.getByRole('timer')).toHaveAttribute('aria-label', 'Lap timer: 0:00.000');
+ });
+});
diff --git a/frontend/packages/components/tests/StandingsTable.test.ts b/frontend/packages/components/tests/StandingsTable.test.ts
new file mode 100644
index 0000000..3201735
--- /dev/null
+++ b/frontend/packages/components/tests/StandingsTable.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import StandingsTable from '../src/StandingsTable.svelte';
+import { standings } from './fixtures.js';
+
+describe('StandingsTable', () => {
+ it('renders each ranking entry with its tie-aware position', () => {
+ render(StandingsTable, { entries: standings, caption: 'Open — overall' });
+
+ expect(screen.getByText('Open — overall')).toBeInTheDocument();
+ expect(screen.getByText('ALICE')).toBeInTheDocument();
+
+ // The two pilots tied at position 2 both show "2".
+ const bobRow = screen.getByText('BOB').closest('tr');
+ const bob2Row = screen.getByText('BOB2').closest('tr');
+ expect(bobRow!).toHaveTextContent('2');
+ expect(bob2Row!).toHaveTextContent('2');
+
+ // The next distinct entry skips to 4 (competition ranking).
+ const carmenRow = screen.getByText('CARMEN').closest('tr');
+ expect(carmenRow!).toHaveTextContent('4');
+ });
+});
diff --git a/frontend/packages/components/tests/fixtures.ts b/frontend/packages/components/tests/fixtures.ts
new file mode 100644
index 0000000..9f2220b
--- /dev/null
+++ b/frontend/packages/components/tests/fixtures.ts
@@ -0,0 +1,90 @@
+/**
+ * Typed test fixtures — representative projection data for the component tests.
+ *
+ * Every fixture is typed against `@gridfpv/types` (the generated-bindings seam)
+ * or the local bracket view-model, so a contract change surfaces as a type error
+ * here rather than silently passing a wrong shape into a component.
+ */
+import type {
+ HeatResult,
+ RankEntry,
+ LiveRaceState,
+ PilotProgress,
+ CompetitorRef
+} from '@gridfpv/types';
+import type { Bracket } from '../src/bracket.js';
+
+export const heatResult: HeatResult = {
+ places: [
+ {
+ competitor: { adapter: 'rh-1', competitor: 'ALICE' },
+ position: 1,
+ laps: 3,
+ metric: { BestLapMicros: 41_250_000n }
+ },
+ {
+ competitor: { adapter: 'rh-1', competitor: 'BOB' },
+ position: 2,
+ laps: 3,
+ metric: { BestLapMicros: 42_100_000n }
+ },
+ {
+ competitor: { adapter: 'rh-1', competitor: 'CARMEN' },
+ position: 3,
+ laps: 2,
+ metric: { BestLapMicros: null }
+ }
+ ]
+};
+
+export const standings: RankEntry[] = [
+ { competitor: 'ALICE', position: 1 },
+ { competitor: 'BOB', position: 2 },
+ { competitor: 'BOB2', position: 2 },
+ { competitor: 'CARMEN', position: 4 }
+];
+
+export const pilotProgress: PilotProgress = {
+ competitor: 'ALICE',
+ laps_completed: 2,
+ last_lap_micros: 41_250_000n
+};
+
+export const liveState: LiveRaceState = {
+ current_heat: 'heat-1',
+ phase: 'Running',
+ active_pilots: ['ALICE', 'BOB', 'CARMEN'] as CompetitorRef[],
+ progress: [
+ { competitor: 'ALICE', laps_completed: 3, last_lap_micros: 41_000_000n },
+ { competitor: 'BOB', laps_completed: 2, last_lap_micros: 43_000_000n },
+ { competitor: 'CARMEN', laps_completed: 2, last_lap_micros: undefined }
+ ],
+ running_order: ['ALICE', 'BOB', 'CARMEN'] as CompetitorRef[]
+};
+
+export const bracket: Bracket = {
+ rounds: [
+ {
+ name: 'Semifinals',
+ matches: [
+ {
+ heat: 'sf-1',
+ slots: [{ competitor: 'ALICE', winner: true }, { competitor: 'DANA' }]
+ },
+ {
+ heat: 'sf-2',
+ slots: [{ competitor: 'BOB', winner: true }, { competitor: 'CARMEN' }]
+ }
+ ]
+ },
+ {
+ name: 'Final',
+ matches: [
+ {
+ heat: 'final',
+ slots: [{ competitor: 'ALICE', winner: true }, { competitor: 'BOB' }]
+ }
+ ]
+ }
+ ]
+};
diff --git a/frontend/packages/components/tests/format.test.ts b/frontend/packages/components/tests/format.test.ts
new file mode 100644
index 0000000..61049ac
--- /dev/null
+++ b/frontend/packages/components/tests/format.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+import { formatClock, formatMicros, formatMetric, medalFor } from '../src/format.js';
+import type { Metric } from '@gridfpv/types';
+
+describe('formatClock', () => {
+ it('formats milliseconds as M:SS.mmm', () => {
+ expect(formatClock(0)).toBe('0:00.000');
+ expect(formatClock(83_456)).toBe('1:23.456');
+ expect(formatClock(605_007)).toBe('10:05.007');
+ });
+ it('clamps negatives to zero', () => {
+ expect(formatClock(-100)).toBe('0:00.000');
+ });
+});
+
+describe('formatMicros', () => {
+ it('renders sub-minute durations as S.mmm seconds', () => {
+ expect(formatMicros(41_250_000n)).toBe('41.250');
+ expect(formatMicros(1_500_000n)).toBe('1.500');
+ });
+ it('rolls over to M:SS.mmm at or above a minute', () => {
+ expect(formatMicros(83_456_000n)).toBe('1:23.456');
+ });
+ it('renders null/undefined as an em dash', () => {
+ expect(formatMicros(null)).toBe('—');
+ expect(formatMicros(undefined)).toBe('—');
+ });
+});
+
+describe('formatMetric', () => {
+ it('formats each win-condition metric variant', () => {
+ expect(formatMetric({ BestLapMicros: 41_250_000n } as Metric)).toBe('41.250');
+ expect(formatMetric({ BestConsecutiveMicros: 120_000_000n } as Metric)).toBe('2:00.000');
+ expect(formatMetric({ BestLapMicros: null } as Metric)).toBe('—');
+ expect(formatMetric({ LastLapAt: 5n } as Metric)).toBe('banked');
+ expect(formatMetric({ ReachedAt: null } as Metric)).toBe('—');
+ });
+});
+
+describe('medalFor', () => {
+ it('maps the podium and nothing below it', () => {
+ expect(medalFor(1)).toBe('gold');
+ expect(medalFor(2)).toBe('silver');
+ expect(medalFor(3)).toBe('bronze');
+ expect(medalFor(4)).toBeNull();
+ });
+});
diff --git a/frontend/packages/components/tsconfig.test.json b/frontend/packages/components/tsconfig.test.json
new file mode 100644
index 0000000..297ae45
--- /dev/null
+++ b/frontend/packages/components/tsconfig.test.json
@@ -0,0 +1,11 @@
+{
+ // Type-checks the component tests (kept out of `src` so svelte-package never
+ // packages them into `dist`). Run by the `check` script; Vitest runs them at
+ // runtime via vitest.config.ts.
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "types": ["svelte", "vitest/globals", "@testing-library/jest-dom"]
+ },
+ "include": ["tests", "vitest.setup.ts"]
+}
diff --git a/frontend/packages/components/vitest.config.ts b/frontend/packages/components/vitest.config.ts
new file mode 100644
index 0000000..6d39ad2
--- /dev/null
+++ b/frontend/packages/components/vitest.config.ts
@@ -0,0 +1,28 @@
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vitest/config';
+import { svelte } from '@sveltejs/vite-plugin-svelte';
+import { svelteTesting } from '@testing-library/svelte/vite';
+
+// Components are typed against `@gridfpv/types`, whose source re-exports the
+// ts-rs bindings through the `@bindings/*` alias (see tsconfig.base.json). Vitest
+// uses Vite's resolver, not tsconfig `paths`, so mirror the alias here pointing at
+// the repo-root `bindings/`. Those imports are all `export type`, so nothing of
+// the bindings executes — this only satisfies resolution.
+export default defineConfig({
+ // The components are plain Svelte 5 + plain CSS, so they need no preprocessor.
+ // Skipping `vitePreprocess()` here avoids Vite's `preprocessCSS` running inside
+ // the Vitest worker (which trips on a partial Vite environment); production
+ // builds still go through svelte.config.js's `vitePreprocess()` via svelte-package.
+ plugins: [svelte({ preprocess: [] }), svelteTesting()],
+ resolve: {
+ alias: {
+ '@bindings': fileURLToPath(new URL('../../../bindings', import.meta.url))
+ }
+ },
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./vitest.setup.ts'],
+ include: ['tests/**/*.test.ts']
+ }
+});
diff --git a/frontend/packages/components/vitest.setup.ts b/frontend/packages/components/vitest.setup.ts
new file mode 100644
index 0000000..ae97d62
--- /dev/null
+++ b/frontend/packages/components/vitest.setup.ts
@@ -0,0 +1,3 @@
+// Extends Vitest's `expect` with jest-dom matchers (toBeInTheDocument, etc.) and
+// auto-cleans the rendered DOM between tests (via @testing-library/svelte/vite).
+import '@testing-library/jest-dom/vitest';
diff --git a/frontend/packages/types/src/generated.ts b/frontend/packages/types/src/generated.ts
index ebd4462..8d0b8de 100644
--- a/frontend/packages/types/src/generated.ts
+++ b/frontend/packages/types/src/generated.ts
@@ -55,6 +55,7 @@ export type * from '@bindings/Metric';
export type * from '@bindings/Pass';
export type * from '@bindings/Penalty';
export type * from '@bindings/PilotId';
+export type * from '@bindings/PilotProgress';
export type * from '@bindings/Placement';
export type * from '@bindings/ProjectionBody';
export type * from '@bindings/ProjectionKind';
From 2888da82be93fb011325fe3b1bbcec3da9c9e713 Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 04:15:01 +0000
Subject: [PATCH 008/362] Add the RD control write path: validate, append, ack
(#45)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implement the privileged control surface (protocol.html §5): a handler
that turns a `Command` into validated, appended events through the one
write path (`AppState::append`), plus the bidirectional control endpoints.
- `control_handler::apply_command` maps each `Command` to its event:
heat-loop commands fold the heat's current `HeatState`
(`heat::heat_state`) and reuse `heat::apply` for legality — a legal
command appends `HeatStateChanged`, an `IllegalTransition` becomes a
`BadRequest` `ProtocolError`, an unknown heat an `UnknownScope` one,
and nothing is appended on rejection. `ScheduleHeat` appends
`HeatScheduled`; the five marshaling commands append their adjudication
events, validating cheap targets (a `LogRef` is a real `Pass`, a heat
exists). `Register` is acknowledged as not-yet-modelled (no
pilot-binding event in the log; Architecture §9) and appends nothing.
- Endpoints: `GET /control` (the bidirectional control WebSocket §5 calls
for — command frames up, an ack per command down) and `POST /control`
(one-shot request/reply), both driving the same handler, composed onto
the router via `control_routes`.
- Auth hook for #44: a `ControlAuth` marker extractor every control route
demands first, infallible today; #44 swaps in the real RD-role check
without touching a handler body.
- The resulting state reaches the RD over the read stream, not the ack:
the append wakes every parked `/stream` (#43), which re-folds and pushes
the change.
Tests (no Docker): legal Stage→Arm→Start→Finish→Score appends the right
transitions and acks ok; an illegal transition is rejected with the
shared error shape and appends nothing; a marshaling command appends its
adjudication; and an integration test drives `POST`/`WS` control and
asserts a `/stream` subscriber observes the resulting change.
Part of #45.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
crates/server/src/app.rs | 8 +-
crates/server/src/control_handler.rs | 563 +++++++++++++++++++++++++++
crates/server/src/lib.rs | 1 +
crates/server/tests/control.rs | 239 ++++++++++++
4 files changed, 808 insertions(+), 3 deletions(-)
create mode 100644 crates/server/src/control_handler.rs
create mode 100644 crates/server/tests/control.rs
diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs
index 11b5310..8543334 100644
--- a/crates/server/src/app.rs
+++ b/crates/server/src/app.rs
@@ -241,14 +241,16 @@ impl AppState {
/// The WS stream (#43), auth middleware (#44), and control routes (#45) layer onto this
/// same router and state.
pub fn router(state: AppState) -> Router {
- Router::new()
+ let read = Router::new()
.route("/health", get(|| async { "ok" }))
.route("/snapshot/event/{event}", get(snapshot_event))
.route("/snapshot/class/{event}/{class}", get(snapshot_class))
.route("/snapshot/heat/{heat}", get(snapshot_heat))
.route("/snapshot/pilot/{event}/{pilot}", get(snapshot_pilot))
- .route("/stream", get(crate::ws::stream_handler))
- .with_state(state)
+ .route("/stream", get(crate::ws::stream_handler));
+ // The privileged RD control surface (§5) is composed on separately so #44 can wrap
+ // just these routes in its auth layer.
+ crate::control_handler::control_routes(read).with_state(state)
}
/// The projection a heat-scope snapshot returns. Selected by `?projection=…`; defaults to
diff --git a/crates/server/src/control_handler.rs b/crates/server/src/control_handler.rs
new file mode 100644
index 0000000..fa3cd00
--- /dev/null
+++ b/crates/server/src/control_handler.rs
@@ -0,0 +1,563 @@
+//! The RD control **write path** (protocol.html §5) — issue #45.
+//!
+//! [`control`](crate::control) defines the command *vocabulary*; this module is the
+//! handler that turns a [`Command`] into validated, appended [`Event`]s and the axum
+//! routes that carry it. Control is the one **bidirectional** protocol surface (§5):
+//! commands up, [`CommandAck`]s down, on a distinct privileged endpoint — while the
+//! *resulting state* flows back down the ordinary read stream (#43), because a command's
+//! whole job is **validate → append → ack**. The append goes through the very same
+//! [`AppState::append`] the change stream observes, so the moment a command is accepted
+//! every subscribed `/stream` re-folds and pushes the new value (see "the resulting state
+//! reaches the stream" below).
+//!
+//! # Command → Event mapping
+//!
+//! | command group | validation | appended event |
+//! |---------------|------------|----------------|
+//! | heat-loop (`Stage`/`Arm`/`Start`/`Finish`/`Score`/`Advance`/`Abort`/`Restart`/`Discard`) | [`heat::heat_state`] folds the heat's current state; [`heat::apply`] checks the transition is legal | [`Event::HeatStateChanged`] with the engine-returned [`HeatTransition`](gridfpv_events::HeatTransition) |
+//! | [`Command::ScheduleHeat`] | none (it creates the heat) | [`Event::HeatScheduled`] |
+//! | [`Command::Register`] | — | **deferred** — see below |
+//! | [`Command::VoidDetection`] | the `target` offset exists and is a [`Pass`](gridfpv_events::Pass) | [`Event::DetectionVoided`] |
+//! | [`Command::AdjustLap`] | the `target` offset exists and is a [`Pass`](gridfpv_events::Pass) | [`Event::LapAdjusted`] |
+//! | [`Command::InsertLap`] | none (it adds a pass) | [`Event::LapInserted`] |
+//! | [`Command::VoidHeat`] | the heat exists in the log | [`Event::HeatVoided`] |
+//! | [`Command::ApplyPenalty`] | the heat exists in the log | [`Event::PenaltyApplied`] |
+//!
+//! ## Legality lives in the engine (reused, not re-implemented)
+//!
+//! A heat-loop command does **not** re-derive the FSM here: it folds the heat's current
+//! [`HeatState`](gridfpv_engine::heat::HeatState) with [`heat::heat_state`] over the log,
+//! then calls [`heat::apply`] — the single source of FSM legality (race-engine.html §2).
+//! A legal command yields the [`HeatTransition`](gridfpv_events::HeatTransition) to record;
+//! an [`IllegalTransition`](gridfpv_engine::heat::IllegalTransition) maps to a
+//! [`ProtocolError`] of [`ErrorCode::BadRequest`]. A command on a heat that was never
+//! scheduled (no `HeatScheduled` in the log, so `heat_state` is `None`) is rejected with
+//! [`ErrorCode::UnknownScope`] — nothing is appended.
+//!
+//! ## Register is deferred (a model gap, not a protocol gap)
+//!
+//! The event log (`gridfpv-events`) carries **no pilot-binding event** — there is a
+//! [`CompetitorSeen`](gridfpv_events::Event::CompetitorSeen) *adapter observation*, but no
+//! event that records "this source competitor *is* this event-scoped pilot" (Architecture
+//! §9; the same gap the snapshot path notes for pilot scope). Rather than append a
+//! lossy stand-in, [`Command::Register`] is acknowledged as **not yet modelled** with a
+//! [`ProtocolError`] of [`ErrorCode::BadRequest`] that names the deferral, and **nothing
+//! is appended**. When the registration event lands in the log model this becomes a
+//! one-line append like the others; the command vocabulary and endpoint already carry it.
+//!
+//! # Endpoints — the privileged control channel (protocol.html §5)
+//!
+//! Both shapes drive the *same* [`apply_command`] handler:
+//!
+//! - **`GET /control`** — the bidirectional control WebSocket §5 calls for ("another
+//! reason the RD wants WebSocket"): the RD sends a stream of JSON [`Command`] frames and
+//! receives a JSON [`CommandAck`] per command on the same socket. This is the primary
+//! surface.
+//! - **`POST /control`** — a one-shot `Command` → `CommandAck` for a simple request/reply
+//! caller (a script, a test) that does not want a long-lived socket.
+//!
+//! # Auth hook for #44
+//!
+//! Control is authenticated and Director-local (§5); **this issue does not implement
+//! auth**. The seam is the [`ControlAuth`] marker extractor: every control route lists it
+//! as its first extractor, so #44 replaces its permissive stub with a real RD-role check
+//! (a token/role extractor that rejects an unprivileged caller with
+//! [`ErrorCode::Unauthorized`]) **without touching the handlers** — the routes already
+//! demand it, the wiring already threads it. Until then it admits every caller (the read
+//! paths are equally unauthenticated pre-#44).
+//!
+//! # How the resulting state reaches the stream (protocol.html §3, §5)
+//!
+//! The ack carries only success/failure (the [`CommandAck`] shape, §5): the *state* a
+//! command produces is **not** echoed in the ack. Instead [`apply_command`] appends through
+//! [`AppState::append`], which appends to the one log **and** `notify_waiters()` wakes every
+//! parked change stream (#43). A subscriber to the affected scope therefore re-folds the new
+//! log tail and pushes the resulting [`ChangeEnvelope`](crate::stream::ChangeEnvelope) — the
+//! RD sees the consequence of its own command arrive on the read stream it already holds, in
+//! the same total order as every other client (§3). The control test below asserts exactly
+//! this: after a control append, a `/stream` subscriber observes the change.
+
+use axum::Json;
+use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
+use axum::extract::{FromRequestParts, State};
+use axum::http::request::Parts;
+use axum::response::Response;
+use axum::routing::get;
+use axum::{Router, routing::MethodRouter};
+use gridfpv_engine::heat::{self, HeatCommand};
+use gridfpv_events::{Event, HeatId, LogRef};
+
+use crate::app::AppState;
+use crate::control::{Command, CommandAck};
+use crate::error::{ErrorCode, ProtocolError};
+
+/// The **auth seam** for the privileged control path (protocol.html §5), left for #44.
+///
+/// An axum extractor every control route demands *before* its handler runs. Today its
+/// extraction is infallible — it admits every caller, exactly as the read paths do
+/// pre-#44 — so the control path is reachable for this issue's write-path work without
+/// auth being implemented here.
+///
+/// #44 makes this the single chokepoint: it replaces the permissive
+/// [`from_request_parts`](FromRequestParts::from_request_parts) below with a real RD-role
+/// check (read the bearer token / session, verify the control role, reject an unprivileged
+/// caller with [`ErrorCode::Unauthorized`]). Because every control route already lists
+/// `ControlAuth` as its first extractor, that change gates `POST /control` and the
+/// `GET /control` upgrade at once **without editing a single handler body**.
+#[derive(Debug, Clone, Copy)]
+pub struct ControlAuth {
+ // A private field so `ControlAuth` can only be minted by the extractor (the auth
+ // chokepoint), never constructed ad hoc by a handler that wants to skip the check.
+ _private: (),
+}
+
+impl FromRequestParts for ControlAuth
+where
+ S: Send + Sync,
+{
+ type Rejection = ProtocolError;
+
+ async fn from_request_parts(_parts: &mut Parts, _state: &S) -> Result {
+ // #44: read the RD credential from `_parts` (Authorization header / session) and
+ // return `Err(ProtocolError::new(ErrorCode::Unauthorized, …))` for an unprivileged
+ // caller. For now control is open, matching the unauthenticated read paths.
+ Ok(ControlAuth { _private: () })
+ }
+}
+
+/// Mount the privileged control routes (protocol.html §5) onto an existing [`Router`].
+///
+/// Adds `GET /control` (the bidirectional control WebSocket) and `POST /control` (the
+/// one-shot request/reply). Kept separate from [`crate::app::router`] so the control
+/// surface is composed explicitly and #44 can wrap *just* these routes in its auth layer.
+pub fn control_routes(router: Router) -> Router {
+ router.route("/control", control_method_router())
+}
+
+/// `GET /control` (WS upgrade) + `POST /control` (one-shot) on the one path.
+fn control_method_router() -> MethodRouter {
+ get(control_ws).post(control_post)
+}
+
+/// `POST /control` — a single [`Command`] in the body, one [`CommandAck`] back
+/// (protocol.html §5). The simple request/reply control surface.
+///
+/// [`ControlAuth`] runs first (the #44 seam); on success the command is dispatched through
+/// [`apply_command`] against the shared log. The ack is always `200 OK` with the
+/// `ok`/`error` body — a *rejected* command (illegal transition, unknown heat) is a
+/// well-formed `CommandAck { ok: false, .. }`, not an HTTP error, so a client reads one
+/// uniform shape (the transport-level errors — a poisoned lock — still surface as the
+/// shared [`ProtocolError`]).
+async fn control_post(
+ _auth: ControlAuth,
+ State(state): State,
+ Json(command): Json,
+) -> Json {
+ Json(apply_command(&state, command))
+}
+
+/// `GET /control` — upgrade to the bidirectional control WebSocket (protocol.html §5).
+///
+/// [`ControlAuth`] gates the upgrade (the #44 seam); the upgraded socket is driven by
+/// [`run_control`].
+async fn control_ws(
+ _auth: ControlAuth,
+ ws: WebSocketUpgrade,
+ State(state): State,
+) -> Response {
+ ws.on_upgrade(move |socket| run_control(socket, state))
+}
+
+/// Drive one control socket: read [`Command`] frames, write a [`CommandAck`] per command
+/// (protocol.html §5).
+///
+/// The RD sends a stream of JSON command frames; for each, [`apply_command`] validates and
+/// (on success) appends, and the ack goes straight back on the same socket. A malformed
+/// frame is answered with a `CommandAck::failed(BadRequest)` rather than closing the
+/// socket, so one bad command does not drop the RD's control session. The loop ends when
+/// the client closes or the socket errors.
+async fn run_control(mut socket: WebSocket, state: AppState) {
+ while let Some(frame) = socket.recv().await {
+ let ack = match frame {
+ Ok(Message::Text(text)) => match serde_json::from_str::(&text) {
+ Ok(command) => apply_command(&state, command),
+ Err(e) => CommandAck::failed(ProtocolError::new(
+ ErrorCode::BadRequest,
+ format!("malformed command: {e}"),
+ )),
+ },
+ Ok(Message::Binary(bytes)) => match serde_json::from_slice::(&bytes) {
+ Ok(command) => apply_command(&state, command),
+ Err(e) => CommandAck::failed(ProtocolError::new(
+ ErrorCode::BadRequest,
+ format!("malformed command: {e}"),
+ )),
+ },
+ // Ping/Pong are handled by axum; a Close (or a transport error) ends the session.
+ Ok(Message::Close(_)) | Err(_) => return,
+ Ok(_) => continue,
+ };
+ let json = match serde_json::to_string(&ack) {
+ Ok(json) => json,
+ Err(_) => return,
+ };
+ if socket.send(Message::Text(json.into())).await.is_err() {
+ return; // client gone
+ }
+ }
+}
+
+/// Validate a [`Command`] against the current log and, on success, append the event(s) it
+/// records (protocol.html §5) — the one control write path, shared by both endpoints.
+///
+/// Reads the log once to fold current state for validation, dispatches per the
+/// command→event table (see the module docs), and appends through [`AppState::append`]
+/// (which wakes the change streams). Returns [`CommandAck::ok`] on a successful append, or
+/// [`CommandAck::failed`] carrying the shared [`ProtocolError`] — and appends **nothing**
+/// — on any rejection.
+pub fn apply_command(state: &AppState, command: Command) -> CommandAck {
+ match command_to_event(state, command) {
+ Ok(event) => match state.append(event, None) {
+ Ok(_offset) => CommandAck::ok(),
+ Err(err) => CommandAck::failed(err),
+ },
+ Err(err) => CommandAck::failed(err),
+ }
+}
+
+/// Validate `command` against the current log and produce the [`Event`] to append, or the
+/// [`ProtocolError`] explaining the rejection. Pure with respect to the log: it reads but
+/// never writes — the append is [`apply_command`]'s job — so a rejected command leaves the
+/// log untouched.
+fn command_to_event(state: &AppState, command: Command) -> Result {
+ match command {
+ // --- Heat-loop transitions: fold current state, reuse the engine's legality. ---
+ Command::Stage { heat } => heat_transition(state, heat, HeatCommand::Stage),
+ Command::Arm { heat } => heat_transition(state, heat, HeatCommand::Arm),
+ Command::Start { heat } => heat_transition(state, heat, HeatCommand::Start),
+ Command::Finish { heat } => heat_transition(state, heat, HeatCommand::Finish),
+ Command::Score { heat } => heat_transition(state, heat, HeatCommand::Score),
+ Command::Advance { heat } => heat_transition(state, heat, HeatCommand::Advance),
+ Command::Abort { heat } => heat_transition(state, heat, HeatCommand::Abort),
+ Command::Restart { heat } => heat_transition(state, heat, HeatCommand::Restart),
+ Command::Discard { heat } => heat_transition(state, heat, HeatCommand::Discard),
+
+ // --- Scheduling: creates the heat, so no prior-state check. ---
+ Command::ScheduleHeat { heat, lineup } => Ok(Event::HeatScheduled { heat, lineup }),
+
+ // --- Registration: no binding event in the log model yet (see module docs). ---
+ Command::Register { .. } => Err(ProtocolError::new(
+ ErrorCode::BadRequest,
+ "registration binding is not yet modelled in the event log \
+ (Architecture §9; deferred — no pilot-binding event exists to append)",
+ )),
+
+ // --- Marshaling adjudications: validate targets where cheap, then append. ---
+ Command::VoidDetection { target } => {
+ require_pass_target(state, target)?;
+ Ok(Event::DetectionVoided { target })
+ }
+ Command::AdjustLap { target, at } => {
+ require_pass_target(state, target)?;
+ Ok(Event::LapAdjusted { target, at })
+ }
+ Command::InsertLap {
+ adapter,
+ competitor,
+ at,
+ } => Ok(Event::LapInserted {
+ adapter,
+ competitor,
+ at,
+ }),
+ Command::VoidHeat { heat } => {
+ require_scheduled_heat(state, &heat)?;
+ Ok(Event::HeatVoided { heat })
+ }
+ Command::ApplyPenalty {
+ heat,
+ competitor,
+ penalty,
+ } => {
+ require_scheduled_heat(state, &heat)?;
+ Ok(Event::PenaltyApplied {
+ heat,
+ competitor,
+ penalty,
+ })
+ }
+ }
+}
+
+/// Fold the heat's current state from the log, validate `command` against it with the
+/// engine's [`heat::apply`], and return the [`Event::HeatStateChanged`] it records.
+///
+/// - The heat must have been scheduled (`heat_state` is `Some`), else
+/// [`ErrorCode::UnknownScope`].
+/// - The transition must be legal in the current state, else [`ErrorCode::BadRequest`]
+/// (the [`IllegalTransition`](gridfpv_engine::heat::IllegalTransition) message).
+fn heat_transition(
+ state: &AppState,
+ heat: HeatId,
+ command: HeatCommand,
+) -> Result {
+ let (events, _cursor) = state.read()?;
+ let current = heat::heat_state(&events, &heat).ok_or_else(|| {
+ ProtocolError::new(
+ ErrorCode::UnknownScope,
+ format!("no heat scheduled with id {:?}", heat.0),
+ )
+ })?;
+ let transition = heat::apply(current, command)
+ .map_err(|illegal| ProtocolError::new(ErrorCode::BadRequest, illegal.to_string()))?;
+ Ok(Event::HeatStateChanged { heat, transition })
+}
+
+/// Require that `heat` was scheduled in the log (a `HeatScheduled` for it), else
+/// [`ErrorCode::UnknownScope`]. The cheap existence check the marshaling heat commands run.
+fn require_scheduled_heat(state: &AppState, heat: &HeatId) -> Result<(), ProtocolError> {
+ let (events, _cursor) = state.read()?;
+ let scheduled = events
+ .iter()
+ .any(|e| matches!(e, Event::HeatScheduled { heat: h, .. } if h == heat));
+ if scheduled {
+ Ok(())
+ } else {
+ Err(ProtocolError::new(
+ ErrorCode::UnknownScope,
+ format!("no heat scheduled with id {:?}", heat.0),
+ ))
+ }
+}
+
+/// Require that `target` names a real [`Pass`](gridfpv_events::Pass) in the log — the cheap
+/// target check for the offset-addressed marshaling commands (`VoidDetection`,
+/// `AdjustLap`). An out-of-range or non-pass offset is [`ErrorCode::BadRequest`]; nothing
+/// is appended.
+fn require_pass_target(state: &AppState, target: LogRef) -> Result<(), ProtocolError> {
+ let (events, _cursor) = state.read()?;
+ match events.get(target.0 as usize) {
+ Some(Event::Pass(_)) => Ok(()),
+ Some(_) => Err(ProtocolError::new(
+ ErrorCode::BadRequest,
+ format!("log offset {} is not a detected pass", target.0),
+ )),
+ None => Err(ProtocolError::new(
+ ErrorCode::BadRequest,
+ format!("log offset {} is out of range", target.0),
+ )),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use gridfpv_events::{
+ AdapterId, CompetitorRef, GateIndex, HeatTransition, Pass, Penalty, SourceTime,
+ };
+ use gridfpv_storage::{EventLog, InMemoryLog};
+
+ fn heat() -> HeatId {
+ HeatId("q-1".into())
+ }
+
+ /// A state whose log already has `q-1` scheduled.
+ fn scheduled_state() -> AppState {
+ let mut log = InMemoryLog::default();
+ EventLog::append(
+ &mut log,
+ Event::HeatScheduled {
+ heat: heat(),
+ lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())],
+ },
+ None,
+ )
+ .unwrap();
+ AppState::new(log)
+ }
+
+ fn pass(competitor: &str, at: i64, seq: u64) -> Event {
+ Event::Pass(Pass {
+ adapter: AdapterId("vd".into()),
+ competitor: CompetitorRef(competitor.into()),
+ at: SourceTime::from_micros(at),
+ sequence: Some(seq),
+ gate: GateIndex::LAP,
+ signal: None,
+ })
+ }
+
+ /// (a) A legal Stage→Arm→Start→Finish→Score sequence acks ok and appends the matching
+ /// `HeatStateChanged` events in order.
+ #[test]
+ fn legal_heat_loop_sequence_appends_transitions_and_acks_ok() {
+ let state = scheduled_state();
+ let steps = [
+ (Command::Stage { heat: heat() }, HeatTransition::Staged),
+ (Command::Arm { heat: heat() }, HeatTransition::Armed),
+ (Command::Start { heat: heat() }, HeatTransition::Running),
+ (Command::Finish { heat: heat() }, HeatTransition::Finished),
+ (Command::Score { heat: heat() }, HeatTransition::Scored),
+ ];
+ for (command, _expected) in steps.iter().cloned() {
+ let ack = apply_command(&state, command);
+ assert!(ack.ok, "expected ok ack, got {ack:?}");
+ assert!(ack.error.is_none());
+ }
+
+ // The log now holds the scheduling plus one HeatStateChanged per step, in order.
+ let (events, _) = state.read().unwrap();
+ let transitions: Vec = events
+ .iter()
+ .filter_map(|e| match e {
+ Event::HeatStateChanged { transition, .. } => Some(*transition),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(
+ transitions,
+ steps.iter().map(|(_, t)| *t).collect::>(),
+ );
+ }
+
+ /// (b) An illegal transition (Start before Arm) is rejected with the shared error shape
+ /// and appends nothing.
+ #[test]
+ fn illegal_transition_is_rejected_and_appends_nothing() {
+ let state = scheduled_state();
+ let (before, _) = state.read().unwrap();
+
+ let ack = apply_command(&state, Command::Start { heat: heat() });
+ assert!(!ack.ok);
+ let err = ack.error.expect("a failed ack carries the error");
+ assert_eq!(err.code, ErrorCode::BadRequest);
+
+ // Nothing was appended — the log is unchanged.
+ let (after, _) = state.read().unwrap();
+ assert_eq!(
+ before.len(),
+ after.len(),
+ "illegal command appended nothing"
+ );
+ }
+
+ /// A command on a heat that was never scheduled is an UnknownScope rejection.
+ #[test]
+ fn command_on_unknown_heat_is_rejected() {
+ let state = scheduled_state();
+ let ack = apply_command(
+ &state,
+ Command::Stage {
+ heat: HeatId("does-not-exist".into()),
+ },
+ );
+ assert!(!ack.ok);
+ assert_eq!(ack.error.unwrap().code, ErrorCode::UnknownScope);
+ }
+
+ /// `ScheduleHeat` creates the heat with its lineup.
+ #[test]
+ fn schedule_heat_appends_heat_scheduled() {
+ let state = AppState::new(InMemoryLog::default());
+ let lineup = vec![CompetitorRef("A".into()), CompetitorRef("B".into())];
+ let ack = apply_command(
+ &state,
+ Command::ScheduleHeat {
+ heat: heat(),
+ lineup: lineup.clone(),
+ },
+ );
+ assert!(ack.ok);
+ let (events, _) = state.read().unwrap();
+ assert!(events.iter().any(|e| matches!(
+ e,
+ Event::HeatScheduled { heat: h, lineup: l } if *h == heat() && *l == lineup
+ )));
+ }
+
+ /// (c) A marshaling command appends the right adjudication event.
+ #[test]
+ fn apply_penalty_appends_penalty_event() {
+ let state = scheduled_state();
+ let penalty = Penalty::TimeAdded { micros: 2_000_000 };
+ let ack = apply_command(
+ &state,
+ Command::ApplyPenalty {
+ heat: heat(),
+ competitor: CompetitorRef("A".into()),
+ penalty,
+ },
+ );
+ assert!(ack.ok, "got {ack:?}");
+ let (events, _) = state.read().unwrap();
+ assert!(events.iter().any(|e| matches!(
+ e,
+ Event::PenaltyApplied { heat: h, competitor: c, penalty: p }
+ if *h == heat() && *c == CompetitorRef("A".into()) && *p == penalty
+ )));
+ }
+
+ /// `VoidDetection` validates the target is a real pass, then appends the adjudication.
+ #[test]
+ fn void_detection_validates_target_and_appends() {
+ let mut log = InMemoryLog::default();
+ // offset 0: a pass; offset 1: a non-pass.
+ EventLog::append(&mut log, pass("A", 1_000_000, 1), None).unwrap();
+ EventLog::append(
+ &mut log,
+ Event::HeatScheduled {
+ heat: heat(),
+ lineup: vec![],
+ },
+ None,
+ )
+ .unwrap();
+ let state = AppState::new(log);
+
+ // Voiding the pass at offset 0 succeeds and appends.
+ let ack = apply_command(&state, Command::VoidDetection { target: LogRef(0) });
+ assert!(ack.ok, "got {ack:?}");
+ let (events, _) = state.read().unwrap();
+ assert!(
+ events
+ .iter()
+ .any(|e| matches!(e, Event::DetectionVoided { target } if *target == LogRef(0)))
+ );
+
+ // A non-pass target is rejected and appends nothing.
+ let (before, _) = state.read().unwrap();
+ let ack = apply_command(&state, Command::VoidDetection { target: LogRef(1) });
+ assert!(!ack.ok);
+ assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest);
+ let (after, _) = state.read().unwrap();
+ assert_eq!(before.len(), after.len());
+
+ // An out-of-range target is rejected too.
+ let ack = apply_command(
+ &state,
+ Command::VoidDetection {
+ target: LogRef(999),
+ },
+ );
+ assert!(!ack.ok);
+ }
+
+ /// `Register` is acknowledged as not-yet-modelled and appends nothing (the model gap).
+ #[test]
+ fn register_is_deferred_and_appends_nothing() {
+ let state = scheduled_state();
+ let (before, _) = state.read().unwrap();
+ let ack = apply_command(
+ &state,
+ Command::Register {
+ adapter: AdapterId("rh".into()),
+ competitor: CompetitorRef("node-2".into()),
+ pilot: crate::scope::PilotId("acroace".into()),
+ },
+ );
+ assert!(!ack.ok);
+ assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest);
+ let (after, _) = state.read().unwrap();
+ assert_eq!(before.len(), after.len());
+ }
+}
diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs
index 4381323..1b6a6f3 100644
--- a/crates/server/src/lib.rs
+++ b/crates/server/src/lib.rs
@@ -58,6 +58,7 @@
pub mod app;
pub mod control;
+pub mod control_handler;
pub mod error;
pub mod live_state;
pub mod scope;
diff --git a/crates/server/tests/control.rs b/crates/server/tests/control.rs
new file mode 100644
index 0000000..dfde0ec
--- /dev/null
+++ b/crates/server/tests/control.rs
@@ -0,0 +1,239 @@
+//! RD control write-path integration tests (protocol.html §5) — issue #45.
+//!
+//! These spin the real [`router`] on an ephemeral `127.0.0.1` port with `axum::serve` and
+//! drive the privileged control surface end to end — no Docker, no mocks of the transport:
+//!
+//! - `POST /control` — one [`Command`] in, one [`CommandAck`] back;
+//! - `GET /control` — the bidirectional control WebSocket (commands up, acks down);
+//! - and crucially the **read-back**: after a control append, a `/stream` subscriber
+//! observes the resulting change (§3, §5 — the resulting state reaches the RD on the read
+//! stream, not in the ack).
+//!
+//! Determinism: every command is explicit and each expected frame is awaited under a short
+//! timeout, so there is no reliance on timing.
+
+use std::time::Duration;
+
+use futures_util::{SinkExt, StreamExt};
+use gridfpv_events::{CompetitorRef, HeatId};
+use gridfpv_server::app::{AppState, router};
+use gridfpv_server::control::{Command, CommandAck};
+use gridfpv_server::error::ErrorCode;
+use gridfpv_server::scope::{Scope, SubscribeRequest};
+use gridfpv_server::snapshot::{HeatPhase, ProjectionBody};
+use gridfpv_server::stream::{Change, StreamMessage};
+use gridfpv_storage::InMemoryLog;
+use tokio::net::TcpStream;
+use tokio_tungstenite::tungstenite::Message;
+use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
+
+type Ws = WebSocketStream>;
+
+/// Serve `router(state)` on an ephemeral port; return the base `http://…` address and the
+/// server task handle (dropped at test end, aborting the task).
+async fn serve(state: AppState) -> (String, tokio::task::JoinHandle<()>) {
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap();
+ let app = router(state);
+ let handle = tokio::spawn(async move {
+ axum::serve(listener, app).await.unwrap();
+ });
+ (format!("{addr}"), handle)
+}
+
+fn heat() -> HeatId {
+ HeatId("q-1".into())
+}
+
+/// POST one command to `http://{addr}/control` and return the ack.
+async fn post_command(addr: &str, command: &Command) -> CommandAck {
+ // A tiny manual HTTP/1.1 POST so the test pulls in no extra HTTP client dependency.
+ let body = serde_json::to_string(command).unwrap();
+ let request = format!(
+ "POST /control HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\
+ Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
+ body.len()
+ );
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
+ let mut stream = TcpStream::connect(addr).await.unwrap();
+ stream.write_all(request.as_bytes()).await.unwrap();
+ let mut response = String::new();
+ stream.read_to_string(&mut response).await.unwrap();
+ let json = response
+ .split("\r\n\r\n")
+ .nth(1)
+ .expect("response has a body");
+ serde_json::from_str(json).expect("body is a CommandAck")
+}
+
+/// Connect the control WS at `ws://{addr}/control`.
+async fn control_ws(addr: &str) -> Ws {
+ let (ws, _) = connect_async(format!("ws://{addr}/control")).await.unwrap();
+ ws
+}
+
+/// Send a command frame on the control socket and await its ack frame.
+async fn send_command(ws: &mut Ws, command: &Command) -> CommandAck {
+ ws.send(Message::text(serde_json::to_string(command).unwrap()))
+ .await
+ .unwrap();
+ let frame = tokio::time::timeout(Duration::from_secs(5), ws.next())
+ .await
+ .expect("timed out waiting for an ack")
+ .expect("control socket closed")
+ .expect("websocket error");
+ match frame {
+ Message::Text(text) => serde_json::from_str(&text).expect("parse CommandAck"),
+ other => panic!("expected a text ack, got {other:?}"),
+ }
+}
+
+/// Subscribe a `/stream` reader and await the next live-state phase.
+async fn subscribe_stream(addr: &str, scope: Scope) -> Ws {
+ let (mut ws, _) = connect_async(format!("ws://{addr}/stream")).await.unwrap();
+ let request = SubscribeRequest { scope, from: None };
+ ws.send(Message::text(serde_json::to_string(&request).unwrap()))
+ .await
+ .unwrap();
+ ws
+}
+
+/// Await the next stream message's live-state phase.
+async fn next_phase(ws: &mut Ws) -> HeatPhase {
+ let frame = tokio::time::timeout(Duration::from_secs(5), ws.next())
+ .await
+ .expect("timed out waiting for a stream frame")
+ .expect("stream closed")
+ .expect("websocket error");
+ let message: StreamMessage = match frame {
+ Message::Text(text) => serde_json::from_str(&text).unwrap(),
+ Message::Close(c) => panic!("stream closed: {c:?}"),
+ other => panic!("expected text, got {other:?}"),
+ };
+ match message {
+ StreamMessage::Change(env) => match env.change {
+ Change::FreshValue(ProjectionBody::LiveRaceState(ls)) => ls.phase,
+ other => panic!("expected live-state, got {other:?}"),
+ },
+ other => panic!("expected a Change, got {other:?}"),
+ }
+}
+
+/// `POST /control`: a legal heat-loop command acks ok and the resulting `HeatStateChanged`
+/// reaches a `/stream` subscriber (the read-back, §5).
+#[tokio::test]
+async fn post_command_drives_heat_loop_and_reaches_stream() {
+ let state = AppState::new(InMemoryLog::default());
+ let (addr, _server) = serve(state.clone()).await;
+
+ // Schedule the heat, then subscribe so the subscriber starts from the scheduled state.
+ let ack = post_command(
+ &addr,
+ &Command::ScheduleHeat {
+ heat: heat(),
+ lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())],
+ },
+ )
+ .await;
+ assert!(ack.ok, "schedule should ack ok: {ack:?}");
+
+ let mut stream = subscribe_stream(&addr, Scope::Heat { heat: heat() }).await;
+ // A fresh subscribe replays the log from the start, so the first envelope is the
+ // already-scheduled state; consume it before driving new transitions.
+ assert_eq!(next_phase(&mut stream).await, HeatPhase::Scheduled);
+
+ // Stage via the control path; the subscriber observes the resulting transition.
+ let ack = post_command(&addr, &Command::Stage { heat: heat() }).await;
+ assert!(ack.ok, "stage should ack ok: {ack:?}");
+ assert_eq!(next_phase(&mut stream).await, HeatPhase::Staged);
+
+ // Arm, again read back off the stream.
+ let ack = post_command(&addr, &Command::Arm { heat: heat() }).await;
+ assert!(ack.ok, "arm should ack ok: {ack:?}");
+ assert_eq!(next_phase(&mut stream).await, HeatPhase::Armed);
+}
+
+/// `GET /control` (the bidirectional WS): a Stage→Arm→Start sequence acks ok per command,
+/// an illegal command is rejected with the shared error shape, and the resulting state is
+/// readable on `/stream`.
+#[tokio::test]
+async fn control_ws_acks_each_command_and_rejects_illegal() {
+ let state = AppState::new(InMemoryLog::default());
+ let (addr, _server) = serve(state.clone()).await;
+
+ let mut control = control_ws(&addr).await;
+
+ // Schedule then subscribe.
+ let ack = send_command(
+ &mut control,
+ &Command::ScheduleHeat {
+ heat: heat(),
+ lineup: vec![CompetitorRef("A".into())],
+ },
+ )
+ .await;
+ assert!(ack.ok);
+
+ let mut stream = subscribe_stream(&addr, Scope::Heat { heat: heat() }).await;
+ // Consume the replayed scheduled state (fresh subscribe replays from the start).
+ assert_eq!(next_phase(&mut stream).await, HeatPhase::Scheduled);
+
+ // Legal forward path over the same control socket.
+ for (command, expected) in [
+ (Command::Stage { heat: heat() }, HeatPhase::Staged),
+ (Command::Arm { heat: heat() }, HeatPhase::Armed),
+ (Command::Start { heat: heat() }, HeatPhase::Running),
+ ] {
+ let ack = send_command(&mut control, &command).await;
+ assert!(ack.ok, "{command:?} should ack ok: {ack:?}");
+ assert_eq!(next_phase(&mut stream).await, expected);
+ }
+
+ // An illegal command (Stage while Running) is a failed ack carrying the shared error.
+ let ack = send_command(&mut control, &Command::Stage { heat: heat() }).await;
+ assert!(!ack.ok);
+ assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest);
+
+ // The log still reflects only the legal transitions (nothing was appended for the
+ // rejected command): the next legal command (Finish) acks ok.
+ let ack = send_command(&mut control, &Command::Finish { heat: heat() }).await;
+ assert!(ack.ok, "finish after running should ack ok: {ack:?}");
+ // The `Finished` transition lands in the `Landed` live-state phase.
+ assert_eq!(next_phase(&mut stream).await, HeatPhase::Landed);
+}
+
+/// A malformed control frame is answered with a failed ack, not a dropped socket: the next
+/// well-formed command still works on the same session.
+#[tokio::test]
+async fn control_ws_survives_a_malformed_frame() {
+ let state = AppState::new(InMemoryLog::default());
+ let (addr, _server) = serve(state.clone()).await;
+ let mut control = control_ws(&addr).await;
+
+ control
+ .send(Message::text("{not a command}"))
+ .await
+ .unwrap();
+ let frame = tokio::time::timeout(Duration::from_secs(5), control.next())
+ .await
+ .expect("timed out")
+ .expect("closed")
+ .expect("ws error");
+ let ack: CommandAck = match frame {
+ Message::Text(text) => serde_json::from_str(&text).unwrap(),
+ other => panic!("expected an ack, got {other:?}"),
+ };
+ assert!(!ack.ok);
+ assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest);
+
+ // The session survives — a real command now acks ok.
+ let ack = send_command(
+ &mut control,
+ &Command::ScheduleHeat {
+ heat: heat(),
+ lineup: vec![],
+ },
+ )
+ .await;
+ assert!(ack.ok);
+}
From 2b1b4f5fb5ebdd368877c24b218f244ec82b741a Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 04:26:07 +0000
Subject: [PATCH 009/362] Add RD-token auth, read join-tokens, and
contract-version negotiation (#44, #46)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implement the auth/authorization gate (#44) and contract-version negotiation
plus the uniform error model (#46) on the protocol server.
#44 — Auth & authorization (protocol.html §5, §9.4):
- New `auth` module with an in-memory `TokenStore` (token -> role-bearing
`Session`) on `AppState`: `issue_rd_token()`, read-only `issue_join_token()`,
and instant `revoke()`. Tokens are opaque, URL/QR-safe random strings drawn
from the OS CSPRNG (`getrandom`) — the store is the sole authority, so
revocation is just dropping the session.
- Control is gated at the single `ControlAuth` chokepoint: its extractor now
requires an `Authorization: Bearer ` resolving to a control-
authorized (`Role::Rd`) session; no token / read-only join-token / unknown /
revoked all reject with `ProtocolError{Unauthorized}` (HTTP 401 / failed ack)
— gating both GET+POST /control without touching the handlers.
- Reads stay open on the LAN; a read-only join-token is accepted where presented
(it authenticates reads, never control) but absence of a token is not an error.
#46 — Contract version + error model (protocol.html §7, §9.7, §9.8):
- `MIN_SUPPORTED_CONTRACT_VERSION..=CONTRACT_VERSION` supported band with
`is_supported_contract_version` and `ServerHello::for_client` /
`refresh_error`. The WS subscribe frame doubles as the connect message,
carrying the client's `contract_version` (and an optional read `token`);
an out-of-band version gets the "too old/new, please refresh"
`VersionMismatch` signal before streaming.
- The single shared `ProtocolError`/`ErrorCode` shape is used uniformly across
HTTP/WS/control; the WS close path now sends the typed error as a text frame
(close reasons exceed the 123-byte control-frame limit).
Deferred (noted in module docs): session persistence (in-memory for now) and
real signed/HMAC join-tokens (opaque + store-validated for now).
Regenerated bindings/SubscribeRequest.ts (new optional fields). `cargo xtask ci`
passes (fmt, clippy, tests, gen drift).
Part of #44, #46.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
Cargo.lock | 1 +
bindings/SubscribeRequest.ts | 25 +-
crates/server/Cargo.toml | 5 +
crates/server/src/app.rs | 18 ++
crates/server/src/auth.rs | 362 +++++++++++++++++++++++++++
crates/server/src/control_handler.rs | 41 +--
crates/server/src/lib.rs | 81 ++++++
crates/server/src/scope.rs | 33 ++-
crates/server/src/ws.rs | 39 ++-
crates/server/tests/control.rs | 248 ++++++++++++++++--
crates/server/tests/ws_stream.rs | 8 +
11 files changed, 813 insertions(+), 48 deletions(-)
create mode 100644 crates/server/src/auth.rs
diff --git a/Cargo.lock b/Cargo.lock
index 4520ded..8bd5c21 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -498,6 +498,7 @@ version = "0.1.0"
dependencies = [
"axum",
"futures-util",
+ "getrandom 0.3.4",
"gridfpv-engine",
"gridfpv-events",
"gridfpv-projection",
diff --git a/bindings/SubscribeRequest.ts b/bindings/SubscribeRequest.ts
index 33512c6..af388a4 100644
--- a/bindings/SubscribeRequest.ts
+++ b/bindings/SubscribeRequest.ts
@@ -1,4 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { ContractVersion } from "./ContractVersion";
import type { Cursor } from "./Cursor";
import type { Scope } from "./Scope";
@@ -12,6 +13,14 @@ import type { Scope } from "./Scope";
* cursor, fall back to re-snapshot"). If the gap is too old to replay the server
* answers with a [`StaleCursor`](crate::error::ErrorCode::StaleCursor) error and the
* client re-snapshots.
+ * The subscribe frame doubles as the **connect message** for the WS read path
+ * (protocol.html §7): it carries the client's [`ContractVersion`](crate::ContractVersion)
+ * (`contract_version`) so the server can negotiate the contract band before streaming,
+ * and an optional read **token** (`token`) — a bearer or read-only join-token — so a
+ * gated deployment can authenticate the read on the same frame. Both are optional and
+ * default-absent so an existing fresh subscribe (just a `scope`) is unchanged on the
+ * wire; a missing `contract_version` is treated as this build's [`CONTRACT_VERSION`]
+ * (the legacy/unspecified client), and a missing `token` is an anonymous LAN read.
*/
export type SubscribeRequest = {
/**
@@ -21,4 +30,18 @@ scope: Scope,
/**
* The cursor to resume after, or `None` to start fresh from the snapshot.
*/
-from?: Cursor, };
+from?: Cursor,
+/**
+ * The contract version the client was built against (protocol.html §7). Checked
+ * against the server's supported band before the stream starts; out of band gets the
+ * "please refresh" [`VersionMismatch`](crate::error::ErrorCode::VersionMismatch)
+ * signal. Absent means "unspecified" — treated as the server's own
+ * [`CONTRACT_VERSION`](crate::CONTRACT_VERSION).
+ */
+contract_version?: ContractVersion,
+/**
+ * An optional read credential (protocol.html §5): a bearer or read-only join-token.
+ * Reads are open on the LAN, so this is omitted by an anonymous viewer; when present
+ * it must be valid (an unknown/revoked token is rejected).
+ */
+token?: string, };
diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml
index a972f82..987532d 100644
--- a/crates/server/Cargo.toml
+++ b/crates/server/Cargo.toml
@@ -21,6 +21,11 @@ ts-rs = "12"
axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "macros", "net"] }
+# Auth (#44): opaque bearer + read-only join tokens are random, unguessable strings.
+# `getrandom` draws them straight from the OS CSPRNG — minimal, std-ish, no key
+# management. Real signed/HMAC join tokens are a later concern (see `auth` module docs).
+getrandom = "0.3"
+
[dev-dependencies]
# `tower::ServiceExt::oneshot` drives the snapshot router in integration tests with no
# real network or Docker; `http-body-util` collects the response body to assert on it.
diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs
index 8543334..6173002 100644
--- a/crates/server/src/app.rs
+++ b/crates/server/src/app.rs
@@ -85,6 +85,7 @@ use gridfpv_storage::{EventLog, Offset, Result as StorageResult, StoredEvent};
use serde::Deserialize;
use tokio::sync::Notify;
+use crate::auth::TokenStore;
use crate::error::{ErrorCode, ProtocolError};
use crate::live_state::live_state;
use crate::scope::{ClassId, EventId, PilotId};
@@ -161,6 +162,12 @@ pub struct AppState {
log: SharedLog,
/// Woken on every append so caught-up change streams re-read the log tail.
appended: Arc,
+ /// The auth authority (#44): opaque bearer/join tokens → role-bearing sessions. Shared
+ /// (it is internally `Arc`'d) so every handler — and the [`ControlAuth`] extractor —
+ /// consults the same sessions; control reads it through [`AppState::tokens`].
+ ///
+ /// [`ControlAuth`]: crate::control_handler::ControlAuth
+ tokens: TokenStore,
}
impl AppState {
@@ -171,6 +178,7 @@ impl AppState {
Self {
log: Arc::new(Mutex::new(log)),
appended: Arc::new(Notify::new()),
+ tokens: TokenStore::new(),
}
}
@@ -180,9 +188,19 @@ impl AppState {
Self {
log,
appended: Arc::new(Notify::new()),
+ tokens: TokenStore::new(),
}
}
+ /// The shared auth token store (#44), for minting/revoking tokens out of band (the RD
+ /// console issues itself an RD token; an operator issues a join-token QR) and for the
+ /// [`ControlAuth`] extractor to authenticate a control caller.
+ ///
+ /// [`ControlAuth`]: crate::control_handler::ControlAuth
+ pub fn tokens(&self) -> &TokenStore {
+ &self.tokens
+ }
+
/// The shared log handle, for tasks that tail or append outside the router.
pub fn log(&self) -> SharedLog {
Arc::clone(&self.log)
diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs
new file mode 100644
index 0000000..10529f3
--- /dev/null
+++ b/crates/server/src/auth.rs
@@ -0,0 +1,362 @@
+//! Auth & authorization — the policy gate in front of the read/realtime/control
+//! contract (protocol.html §5, §9.4) — issue #44.
+//!
+//! §5 fixes the shape of the gate, not its credential format ("the exact token/session
+//! format … is deliberately left open"): the **read** half is open or lightly-tokened
+//! on the LAN, while **control** "requires the RD's authenticated role and runs only
+//! against the Director". §9.4 resolves the open auth decisions this module implements:
+//!
+//! - **Opaque bearer tokens** back a server-side [`Session`] carrying a [`Role`]. They
+//! are opaque random strings (not self-describing JWTs) so the server is the only
+//! authority and a token is **revoked** by deleting its session — no key rotation, no
+//! token blacklist, instant effect ([`TokenStore::revoke`]).
+//! - A lightweight **read-only join-token** (QR/URL-friendly) authenticates a
+//! [`Role::ReadOnly`] session for LAN reads but is **never** accepted on control — a
+//! spectator who scans the venue QR can watch, never run the race ([`Role::can_control`]).
+//!
+//! # How control is gated (the one chokepoint)
+//!
+//! [`ControlAuth`](crate::control_handler::ControlAuth) is the single extractor every
+//! control route demands first. Its body calls [`TokenStore::authenticate_control`] with
+//! the request's `Authorization: Bearer …`: a valid **RD-role** token yields the marker,
+//! anything else (no header, a read-only/join token, a revoked or unknown token) is
+//! [`ProtocolError`] of [`ErrorCode::Unauthorized`] → HTTP 401 / a failed ack. The
+//! handlers never see the credential; gating both `GET`+`POST /control` is one call.
+//!
+//! # How reads are treated
+//!
+//! Reads stay **open on the LAN** (the §5 default): the snapshot `GET`s and the `/stream`
+//! WS subscribe do not *require* a token, matching the pre-#44 behaviour and the "a phone
+//! on the venue Wi-Fi just watches" use case. A read-only **join-token** is *accepted*
+//! where presented (it authenticates a [`Role::ReadOnly`] session) so the same surface
+//! works unchanged when a deployment chooses to gate reads, but absence of a token is not
+//! an error on a read path. Authority only ever *narrows* on control. (The Cloud's
+//! account gate in front of the identical read contract is a later, separate concern.)
+//!
+//! # Deferred (noted, not built)
+//!
+//! - **Persistence.** The [`TokenStore`] is in-memory: tokens live for the process and a
+//! restart invalidates every session (the RD re-issues). Durable sessions are a later
+//! concern when the Director gains a config/identity store.
+//! - **Real signing.** The join-token is an opaque random string, not yet an
+//! HMAC/JWT-signed capability — it is validated by store lookup like the bearer token.
+//! Self-validating signed join-tokens (so an offline relay can verify one without the
+//! issuing store) land with the Cloud/broadcast work; the wire surface (a token string
+//! on the subscribe) does not change when it does.
+
+use std::collections::HashMap;
+use std::sync::{Arc, RwLock};
+
+use axum::extract::FromRequestParts;
+use axum::http::header::AUTHORIZATION;
+use axum::http::request::Parts;
+
+use crate::error::{ErrorCode, ProtocolError};
+
+/// The authority a [`Session`] grants (protocol.html §5).
+///
+/// Two levels are all the contract needs today: the privileged **RD** role that may
+/// drive control, and a **read-only** role (the join-token's level) that may watch but
+/// never control. The distinction is enforced at the one control chokepoint via
+/// [`Role::can_control`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Role {
+ /// The Race Director: authenticated, control-authorized (§5 "the RD's authenticated
+ /// role"). Minted by [`TokenStore::issue_rd_token`].
+ Rd,
+ /// A read-only viewer: may read the contract, never control. The level a join-token
+ /// authenticates (§9.4). Minted by [`TokenStore::issue_join_token`].
+ ReadOnly,
+}
+
+impl Role {
+ /// Whether this role may drive the privileged control path (protocol.html §5).
+ /// Only [`Role::Rd`] can; [`Role::ReadOnly`] never — the join-token "never grants
+ /// control" rule (§9.4).
+ pub fn can_control(self) -> bool {
+ matches!(self, Role::Rd)
+ }
+}
+
+/// A server-side session a token resolves to (protocol.html §5, §9.4).
+///
+/// The token is opaque; the *session* is where the authority lives, so revoking a token
+/// is just dropping its session — no token is self-describing, so none can outlive the
+/// server's decision to revoke it.
+#[derive(Debug, Clone)]
+pub struct Session {
+ /// The authority this session grants.
+ pub role: Role,
+}
+
+/// The in-memory token → [`Session`] store (protocol.html §5, §9.4) — the auth authority.
+///
+/// Opaque random tokens map to sessions carrying a [`Role`]. Mintable
+/// ([`issue_rd_token`](TokenStore::issue_rd_token),
+/// [`issue_join_token`](TokenStore::issue_join_token)) and **revocable**
+/// ([`revoke`](TokenStore::revoke)) — revocation is instant because the token carries no
+/// authority of its own, only a key into this map. Cloning shares the one store (it is an
+/// `Arc>`), so every axum handler and the [`AppState`](crate::app::AppState) it
+/// rides on consult the same sessions.
+///
+/// In-memory by design for v0.4 (persistence deferred — see the module docs).
+#[derive(Clone, Default)]
+pub struct TokenStore {
+ sessions: Arc>>,
+}
+
+impl TokenStore {
+ /// An empty store.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Mint a fresh **RD-role** bearer token, register its control-authorized session, and
+ /// return the opaque token string (protocol.html §5). The RD console presents it as
+ /// `Authorization: Bearer ` on the control path.
+ pub fn issue_rd_token(&self) -> String {
+ self.issue(Role::Rd)
+ }
+
+ /// Mint a fresh **read-only join-token** and register its read-only session
+ /// (protocol.html §9.4). QR/URL-friendly (an opaque URL-safe string); it authenticates
+ /// reads but is rejected on control by [`Role::can_control`].
+ pub fn issue_join_token(&self) -> String {
+ self.issue(Role::ReadOnly)
+ }
+
+ /// Register a session for `role` under a fresh opaque token and return the token.
+ fn issue(&self, role: Role) -> String {
+ let token = random_token();
+ self.sessions
+ .write()
+ .expect("token store lock poisoned")
+ .insert(token.clone(), Session { role });
+ token
+ }
+
+ /// **Revoke** a token by dropping its session (protocol.html §9.4): instant and
+ /// total — the token authenticates nothing afterwards. Returns whether a session was
+ /// actually removed (a no-op for an already-unknown token).
+ pub fn revoke(&self, token: &str) -> bool {
+ self.sessions
+ .write()
+ .expect("token store lock poisoned")
+ .remove(token)
+ .is_some()
+ }
+
+ /// Resolve a token to its [`Session`], or `None` if it is unknown/revoked.
+ pub fn session(&self, token: &str) -> Option {
+ self.sessions
+ .read()
+ .expect("token store lock poisoned")
+ .get(token)
+ .cloned()
+ }
+
+ /// Authenticate a control caller: a token resolving to a **control-authorized**
+ /// session ([`Role::can_control`]) succeeds; anything else — `None` token, an unknown
+ /// or revoked token, or a read-only/join token — is [`ErrorCode::Unauthorized`].
+ ///
+ /// This is the whole control policy in one place; [`ControlAuth`] is the only caller
+ /// (see [`crate::control_handler`]).
+ ///
+ /// [`ControlAuth`]: crate::control_handler::ControlAuth
+ pub fn authenticate_control(&self, token: Option<&str>) -> Result {
+ let token = token.ok_or_else(|| {
+ ProtocolError::new(
+ ErrorCode::Unauthorized,
+ "control requires an Authorization: Bearer header",
+ )
+ })?;
+ match self.session(token) {
+ Some(session) if session.role.can_control() => Ok(session),
+ Some(_) => Err(ProtocolError::new(
+ ErrorCode::Unauthorized,
+ "this token is read-only and may not drive control",
+ )),
+ None => Err(ProtocolError::new(
+ ErrorCode::Unauthorized,
+ "unknown or revoked control token",
+ )),
+ }
+ }
+
+ /// Authenticate a **read** caller (protocol.html §5): reads are open on the LAN, so a
+ /// missing token is allowed (`Ok(None)`); a *present* token must be valid (an
+ /// unknown/revoked token is [`ErrorCode::Unauthorized`] rather than silently treated as
+ /// anonymous). A valid token of either role authenticates a read. The join-token path
+ /// uses this.
+ pub fn authenticate_read(&self, token: Option<&str>) -> Result
+
+
+ As built (v0.4): the gridfpv-server crate owns the protocol
+ wire types (snapshot/stream/control/error/version), generated to TS in bindings/
+ alongside the event model — one definition the Director, Cloud, and every client share.
+ Wire types are generated from Rust with ts-rs across the crates that own contract types —
+ gridfpv-events, gridfpv-projection, gridfpv-engine,
+ and gridfpv-server (the protocol contract) — exported to repo-root
+ bindings/. The protocol contract version is a single monotonic
+ u32 (CONTRACT_VERSION), bumped only on a breaking wire change;
+ additive changes an older client can ignore do not bump it.
+
+
Exact message shapes (snapshot format, the change-stream envelope, auth) are a
protocol spec of their own. The architectural commitments here are: read +
diff --git a/docs/clients.html b/docs/clients.html
index 2b2c9b0..6c352b4 100644
--- a/docs/clients.html
+++ b/docs/clients.html
@@ -16,7 +16,8 @@
Clients
Everything that renders GridFPV is a web client of the one protocol:
- the RD console in the Tauri window, the racer/spectator PWA, the OBS overlays, and (future)
+ the RD console (destined for a Tauri window, shipped as a web app in v0.4), the
+ racer/spectator PWA, the OBS overlays, and (future)
a privileged RD mobile-control surface on the LAN. They
differ only in permissions, transport, and what they emphasize —
not in how they talk to the Director. This doc settles the frontend stack those surfaces
@@ -58,7 +59,7 @@
The RD console — control-oriented, dense, authenticated
- The race director's cockpit, loaded inside the Tauri window (Architecture
- §2). It is the only surface that exercises the protocol's privileged write/control path —
+ The race director's cockpit, designed to load inside the Tauri window
+ (Architecture §2). The Tauri shell and 3-OS single-binary packaging are deferred
+ (#57/#58); v0.4 ships the RD console as a web app served by the Director. It is the
+ only surface that exercises the protocol's privileged write/control path —
arming heats, marshaling, configuring the pipeline — so it authenticates with the RD role
(Architecture §9; mechanics in Protocol). It is information-dense
and keyboard-friendly: a working tool for someone running a live event, not a viewer.
- The console is "just a co-located web client" (Architecture §2): it loads over the
- Tauri webview but talks to the same local axum server, over the same
- protocol, as everyone else. The Tauri shell buys a friendly native window and OS
- integration (file dialogs for export, single-binary packaging) — it does
- not buy a private back channel. Control authority is a protocol role,
- not a shell privilege.
+ The console is "just a co-located web client" (Architecture §2): once the Tauri shell
+ lands it will load over the Tauri webview, but either way it talks to the same local
+ axum server, over the same protocol, as everyone else. The Tauri shell will
+ buy a friendly native window and OS integration (file dialogs for export, single-binary
+ packaging) — it does not buy a private back channel. Control authority is
+ a protocol role, not a shell privilege. As built (v0.4): the Tauri shell and
+ single-binary packaging are deferred (#57/#58); the console is served as a web app by the
+ Director.
tiny bundles suiting lean OBS overlays and the LAN PWA. It matches the lean,
single-binary Tauri ethos rather than fighting it. The contract is the same generated
TypeScript either way, so the realtime/overlay work is comfortably within Svelte's reach.
+
As built (v0.4): Svelte 5 + Vite (no SvelteKit), an npm-workspaces monorepo
+ (packages/{types,protocol-client,components} + apps/rd-console).
@@ -267,6 +274,10 @@
3. Shared component library & generated types
from Rust; the frontend consumes them. The component library is the only place UI lives, so
every surface shows the same data the same way unless it deliberately chooses otherwise.
+
+ As built (v0.4): the frontend uses vitest; the suite is green (78 tests — 20 component,
+ 7 protocol-client, 51 RD console).
+
4. PWA mechanics
@@ -358,10 +369,12 @@
5. Design system & UX
6. Native deferral via Capacitor
Native apps are deferred but kept cheap, by deliberate symmetry with the Director
- (Architecture §7). The Director's RD console is a web UI wrapped in Tauri;
- the racer/spectator PWA can later be wrapped in Capacitor to ship a native
- app to the app stores. In both cases the web protocol-client is the constant, and
- the native shell is an optional enhancement, not a rewrite.
+ (Architecture §7). The Director's RD console is a web UI destined to be wrapped in
+ Tauri (the shell and 3-OS single-binary packaging are deferred, #57/#58 —
+ v0.4 serves it as a web app); the racer/spectator PWA can later be wrapped in
+ Capacitor to ship a native app to the app stores. In both cases the
+ web protocol-client is the constant, and the native shell is an optional
+ enhancement, not a rewrite.
The web client is the constant on both ends. Tauri wraps the
diff --git a/docs/protocol.html b/docs/protocol.html
index 40c5f3a..066c05f 100644
--- a/docs/protocol.html
+++ b/docs/protocol.html
@@ -60,7 +60,7 @@
1. What the protocol serves — projections, not the log
Live race-state
The current heat and its loop state (Scheduled → Staged → Armed → Running →
- Landed → Scored, see Race Engine §2), the active
+ Finished → Scored, see Race Engine §2), the active
pilots and their live lap/split progress, the running order, and the on-deck heat. The
latency-sensitive core every overlay and spectator watches.
Lap lists
@@ -91,6 +91,16 @@
1. What the protocol serves — projections, not the log
contract stable while the log vocabulary and projection internals evolve underneath.
+
+
+ Deferred in v0.4. Several inputs the contract is designed around are not
+ yet wired: the registration-binding event (#60),
+ the event-setup commands (#61), and the LiveRaceState race-start clock plus
+ per-gate splits (#62) are still to come. And the Tauri shell + 3-OS single-binary
+ packaging are not yet built (#57/#58) — v0.4 ships the RD console as a web app
+ served by the Director, not as a native window.
+
+
2. The snapshot read model
@@ -125,6 +135,25 @@
2. The snapshot read model
apart.
+
Endpoint surface (as built, v0.4)
+
+ The concrete HTTP/WS surface the Director serves today, behind the storage trait:
+
+
+
+
Endpoint
What it serves
+
+
+
GET /health
liveness
+
GET /snapshot/event/{event}
event snapshot
+
GET /snapshot/class/{event}/{class}
class snapshot
+
GET /snapshot/heat/{heat} (+ ?projection=live|laps|result)
After the snapshot, the client opens a WebSocket and receives a stream
@@ -140,6 +169,10 @@
3. The realtime change stream
local copy identical to the server's, and the server may collapse a burst into a single
"this projection changed, here's its new value" envelope when a delta would be larger or
ambiguous (e.g. after a marshaling correction that re-folds a whole lap list).
+
As built (v0.4) every envelope is a fresh-value carrying the projection's complete new
+ state (ProjectionBody); the incremental delta encodings are wired in the type
+ system (Change::Delta) but deferred, so the stream is correct-but-chatty until
+ they land.
Sequence, ordering & resume
@@ -240,8 +273,10 @@
4. Subscription scoping
heat.
- Scopes compose (a pilot within a class) and a client may hold several at once over one
- connection. The three client kinds differ in scope and permission:
+ Scopes are designed to compose (a pilot within a class) and a client will eventually hold
+ several over one connection; as built (v0.4) a subscription addresses exactly one of the
+ four resources (event / class / heat / pilot). The three client kinds differ in scope
+ and permission:
@@ -400,44 +435,57 @@
9. Open decisions
regardless, so the contract ships WebSocket-only for every client. The
change envelope stays transport-neutral, so an SSE read-only path can be added later
only if a real spectator/overlay need appears — not by default, to avoid a second
- code path.
+ code path. As built (v0.4): WS-only, no SSE — /stream for
+ reads and /control for the RD path.
RESOLVED (accepted as working design) — Change-envelope schema: delta vs fresh-value, per projection.
The accepted approach is deltas for append-heavy projections (lap lists,
live state) and fresh-value for cheap or re-folded ones (heat result,
ranking after a correction), with a marshaling re-fold surfacing as a fresh-value envelope.
- The exact envelope shapes are refined at implementation.
+ The exact envelope shapes are refined at implementation. As built (v0.4):
+ the delta-vs-fresh split is wired in the type system but all envelopes are
+ FreshValue today; the delta encodings are deferred.
RESOLVED (accepted as working design) — Resume window & retention.
The server retains a bounded window of replayable envelopes before forcing
a re-snapshot: the Director is memory-bounded (one event) and the
Cloud keeps a larger window (many events). Pairs with the storage tiers in
Architecture §4. Exact window sizes are tuned at
- implementation.
+ implementation. As built (v0.4): the retained window is 256 log offsets;
+ an older cursor gets a ReSnapshotRequired / StaleCursor response.
RESOLVED — Auth credential format: opaque bearer tokens (+ optional signed join-token).
RD and account auth use opaque bearer tokens backed by a server-side
session (easy to revoke), plus an optional lightweight signed join-token
(printed as a QR code, baked into the URL) granting read-only LAN access. Trust boundaries
are fixed in Architecture §9. Revisit JWT only if
- stateless Cloud scaling later demands it.
+ stateless Cloud scaling later demands it. As built (v0.4): opaque 256-bit
+ bearer tokens in an in-memory revocable TokenStore, with roles Rd
+ and ReadOnly — reads are open, control is RD-gated, and the read-only join-token
+ grants read scope only.
RESOLVED (accepted as working design) — Sequence-cursor representation.
The public projection sequence is a single monotonic integer per stream
(not per-projection cursors a client composes). How it stays stable across a Director
- restart / projection recompute is settled at implementation.
+ restart / projection recompute is settled at implementation. As built (v0.4):
+ a per-stream u64Cursor counting from 1, distinct from the log
+ offset.
RESOLVED (accepted as working design) — Scope grammar & pagination.
The contract uses a scope grammar for addressing (with multi-scope
subscribe) and cursor-based pagination for large historical / open-data
reads. The exact addressing scheme, URL shapes, and filter expressiveness are refined at
- implementation.
+ implementation. As built (v0.4): concrete REST scope addressing with one
+ scope per subscribe; the richer scope grammar and pagination are deferred.
RESOLVED (accepted as working design) — Contract version negotiation & support band.
The version is carried in the connect message; the server serves a
band of recent versions so an un-refreshed PWA keeps working, and emits an
explicit "too old, please refresh" signal when a client falls outside it.
Paired with log/schema versioning in Architecture §11. The
- exact support-band depth is set at implementation.
+ exact support-band depth is set at implementation. As built (v0.4): a
+ ContractVersion on the WS subscribe, a support band of 1..=1, and
+ a VersionMismatch refresh signal.
RESOLVED (accepted as working design) — Error model. The
contract uses a single shared error shape across HTTP reads, the WS stream,
and control acknowledgements — auth failure, unknown scope, stale-cursor, version-mismatch —
defined once in Rust like the rest of the contract. The exact fields are pinned at
- implementation.
+ implementation. As built (v0.4): a single ProtocolError{code,
+ message} with six ErrorCode variants spanning HTTP, WS, and control.
+
+ Unknown API routes under /snapshot, /stream,
+ /control, /auth, and /health return a typed
+ ProtocolError 404 — not the SPA shell — so a contract miss surfaces as a
+ structured error rather than an HTML page a client can't parse.
+
3. The realtime change stream
@@ -212,6 +219,20 @@
Sequence, ordering & resume
exactly-once effect.
+
+
+ As built (v0.4) the contract is enforced, not just described. The
+ read / realtime / control contract is covered by a strict contract suite
+ — the real protocol client plus raw HTTP/WS, both run against the real Director — asserting
+ the path-scoped snapshot routes (§4), the externally-tagged StreamMessage
+ stream frames (§3), the per-stream sequence-vs-snapshot cursor
+ distinction (§9.5), the control path (headers + auth + acks, §5), and that the wire carries
+ plain numbers (§9). It exists because five integration-seam bugs
+ — all contract drift that had hidden behind mocks, where each side tested its own assumption
+ of the contract rather than the contract itself — motivated pinning the real wire behaviour.
+
+
+
sequenceDiagram
participant C as Client (PWA / overlay)
@@ -370,6 +391,18 @@
6. The contract defined once, in Rust
as a build step that emits the TS module the frontend imports; keeping it offline and
reproducible is part of the build story (deferred to the Roadmap).
+
+
+ As built (v0.4): every transferred integer is a plain number.
+ Microsecond times / durations (i64) and cursors / sequences / log-offsets
+ (u64) all render to TypeScript as a plain number, not a
+ bigint. These values are bounded far below
+ Number.MAX_SAFE_INTEGER (≈9e15) in our domain, so a JS number
+ round-trips them exactly, and serde already serialises them as JSON numbers — so the type
+ matches the wire. A genuinely full-range u64 (e.g. a random id) would instead
+ be carried as a string; we have none today.
+
5.1 Emulated-signal races — driving RH's real pipeline
canonical-log fixtures (§4).
-
6. Testing the format generators
+
6. The frontend wire-contract suite & observability harness
+
+ Two test layers landed in v0.4 that sit between the mocked frontend unit tests and
+ the dockerized mock-RH end-to-end run, and they exist for one reason: the v0.4 integration-seam
+ bugs all passed their unit tests — each side mocked its own assumption of the
+ contract, so the drift between the two sides was exactly what nothing tested.
+
+
+
A real-client ↔ real-Director contract suite at every seam.
+ The contract suite (frontend/contract/, npm run contract) runs the
+ real protocol client against the real Director and asserts actual wire
+ behaviour — the path-scoped snapshot routes, the externally-tagged StreamMessage
+ frames, the per-stream-sequence-vs-snapshot-cursor distinction
+ (Protocol §9.5), the control path (headers + auth + acks), and
+ that the wire carries numbers. It is the layer between the mocked unit tests and
+ the dockerized mock-RH e2e, and it needs no Docker and no browser, so it runs
+ in the shared CI pipeline. It exists because the v0.4 seam bugs each passed their own unit
+ tests while drifting from the contract.
+
+
+
Debug with full-stack observability first. A reusable harness
+ (frontend/test-harness/, npm run observe) boots the real Director and
+ captures the browser console, page errors, WS frames, and server logs together,
+ dumping all of them on any failure. The standing rule that follows: when something breaks,
+ look at full-stack observability — browser console + server logs + wire — before
+ forming a hypothesis. A render-time fault (a thrown exception during render) then
+ surfaces immediately in the console capture instead of presenting as a silent blank page.
+
+
+
7. Testing the format generators
Format / qualifying generators are a pure function of state too
(Race Engine §3): given the seeded field, config, and the
@@ -338,7 +367,7 @@
6. Testing the format generators
this same interface.
-
7. Test levels
+
8. Test levels
The levels mirror the Director's data path — translate in, derive, serve out — and each
proves a different claim. Nothing above unit level touches a live source; replay is the
@@ -348,7 +377,7 @@
7. Test levels
Unit
Pure functions in isolation: adapter translators (golden cases,
§2), the derivation stages (passes→laps→results→standings, §4), and the
- format generators (table-driven, §6). Fast, hermetic, the bulk of the
+ format generators (table-driven, §7). Fast, hermetic, the bulk of the
suite.
Integration — replay
A recorded session (raw or canonical) replayed through the assembled adapter →
@@ -392,7 +421,7 @@
7. Test levels
contract -.-> e2e
-
8. Open decisions
+
9. Open decisions
Resolved — Fixture format & storage. Fixtures are
stored as JSON and checked in alongside the code: per-adapter source/canonical fixtures
From 8aa5689abe82c683ef983319d24e3aa7c5430de2 Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 15:52:15 +0000
Subject: [PATCH 032/362] =?UTF-8?q?docs(conventions):=20correct=20SourceTi?=
=?UTF-8?q?me=20ts-rs=20rendering=20note=20(i64=E2=86=92number)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
docs/code-conventions.html | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/code-conventions.html b/docs/code-conventions.html
index c73a8ab..6dd544d 100644
--- a/docs/code-conventions.html
+++ b/docs/code-conventions.html
@@ -79,8 +79,8 @@
4. Safety & the data model
Integer-microsecond source time. Time is carried as
SourceTime — integer microseconds on the source's own clock
(i64). Interval math uses no floats, so comparisons are stable and tests are
- byte-deterministic. It is serde-transparent / ts(as = "i64"), a
- bare number on the wire.
+ byte-deterministic. It is serde-transparent — a bare integer-microsecond
+ value, rendered to TS as a number (not bigint; see §3).
Externally-tagged events; observations only. The canonical
From c09affaee53ad639ecec835f8643a8f370e9960f Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 16:49:46 +0000
Subject: [PATCH 033/362] Pre-stub format modules: double_elim, round_robin,
multi_main (generator wave base)
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
crates/engine/src/double_elim.rs | 1 +
crates/engine/src/lib.rs | 3 +++
crates/engine/src/multi_main.rs | 1 +
crates/engine/src/round_robin.rs | 1 +
4 files changed, 6 insertions(+)
create mode 100644 crates/engine/src/double_elim.rs
create mode 100644 crates/engine/src/multi_main.rs
create mode 100644 crates/engine/src/round_robin.rs
diff --git a/crates/engine/src/double_elim.rs b/crates/engine/src/double_elim.rs
new file mode 100644
index 0000000..6eabbb8
--- /dev/null
+++ b/crates/engine/src/double_elim.rs
@@ -0,0 +1 @@
+//! double_elim format generator — implements format::Generator (filled by the agent).
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 5b23756..e39ecee 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -14,6 +14,9 @@ pub mod schedule;
pub mod scoring;
// Concrete formats on the `format::Generator` interface (#33/#34/#35).
+pub mod double_elim;
+pub mod multi_main;
+pub mod round_robin;
pub mod single_elim;
pub mod timed_qual;
pub mod zippyq;
diff --git a/crates/engine/src/multi_main.rs b/crates/engine/src/multi_main.rs
new file mode 100644
index 0000000..128e13a
--- /dev/null
+++ b/crates/engine/src/multi_main.rs
@@ -0,0 +1 @@
+//! multi_main format generator — implements format::Generator (filled by the agent).
diff --git a/crates/engine/src/round_robin.rs b/crates/engine/src/round_robin.rs
new file mode 100644
index 0000000..13f30f6
--- /dev/null
+++ b/crates/engine/src/round_robin.rs
@@ -0,0 +1 @@
+//! round_robin format generator — implements format::Generator (filled by the agent).
From 7dcad73e15e1d2e8e4b2a480b919e8676e6d9432 Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 16:57:41 +0000
Subject: [PATCH 034/362] =?UTF-8?q?Add=20multi-tier=20mains=20(A/B/C?=
=?UTF-8?q?=E2=80=A6)=20format=20generator?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implement the MultiGP-standard tiered finals as a `Generator`: partition a
seeded (qualifying-ranked) field into an A main (top `main_size`), B main
(next `main_size`), C main, … down to a possibly-short last main, race each
as an independent heat (`main-A`/`main-B`/…), and concatenate their finishing
orders in tier order so the worst A-main finisher still outranks the best
B-main finisher.
- `MultiMain` (`new`/`from_config`/`register` under `"multi_main"`, `main_size`
default 4). `next` emits every main at once (mains are independent) and
Completes once all are scored; `ranking` concatenates each main's order in
tier bands. Seed order is the qualifying rank, with `SeedingOutcome` applied
at construction. Short last main and a field <= `main_size` (single A main)
handled deterministically. Bump-up variant noted as future work.
- Table tests: tiering/short-main/single-main, all-mains-at-once, completion,
tier-order concatenation, B-winner-below-A-last, provisional ranking,
determinism, registry + main_size param.
- Mock-RH e2e (`multi_main_live.rs`, feature `live`, `#[ignore]`, port 5044):
6 pilots, main_size 3 (A+B) over real heats; asserts termination and the
whole A main ranked above the whole B main.
Part of #13 (multi-tier mains generator).
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
crates/engine/src/multi_main.rs | 465 ++++++++++++++++++++++++-
crates/engine/tests/multi_main_live.rs | 207 +++++++++++
2 files changed, 671 insertions(+), 1 deletion(-)
create mode 100644 crates/engine/tests/multi_main_live.rs
diff --git a/crates/engine/src/multi_main.rs b/crates/engine/src/multi_main.rs
index 128e13a..0ff33ba 100644
--- a/crates/engine/src/multi_main.rs
+++ b/crates/engine/src/multi_main.rs
@@ -1 +1,464 @@
-//! multi_main format generator — implements format::Generator (filled by the agent).
+//! Multi-tier mains generator (#13) — the MultiGP-standard tiered finals: partition a
+//! qualifying-ranked field into an A main, a B main, a C main … and race each as one
+//! heat, then concatenate their finishing orders into the overall standing.
+//!
+//! # The format (race-engine.html §3, §5)
+//!
+//! Multi-tier mains is the classic club finals format. After qualifying produces a
+//! **seeded field** (the seed order *is* the qualifying rank), the field is sliced into
+//! fixed-size tiers, best seeds first:
+//!
+//! - the **A main** is the top `main_size` seeds,
+//! - the **B main** the next `main_size`,
+//! - the **C main** the next, and so on,
+//! - down to a possibly-**short last main** when the field does not divide evenly.
+//!
+//! Each main is an independent heat (`main-A`, `main-B`, …). The mains do not feed each
+//! other — there is no advancement between them in v1 — so the generator emits **all of
+//! them at once** as a single [`GeneratorStep::Run`]; the heat loop runs them in any
+//! order and feeds every result back. (See "bump-up" below for the variant where a lower
+//! main's winner advances up.)
+//!
+//! # Final ranking — concatenation in tier order
+//!
+//! The overall standing is each main's finishing order, **concatenated A, then B, then
+//! C…**: the *worst* finisher of the A main still ranks above the *best* finisher of the
+//! B main, and so on down the tiers. This is the defining property of the format — your
+//! main placement is bounded by the tier you qualified into. Positions are dense and
+//! tie-aware via [`rank_by`], but because each competitor lands in exactly one tier and
+//! the tiers are laid out in strict order, the bands never overlap.
+//!
+//! # Determinism (race-engine.html §6)
+//!
+//! Tiering is a pure slice of the seeded field (the recorded [`SeedingOutcome`] applied
+//! once at construction); the ranking is a pure projection of the completed mains. No
+//! clock, no RNG. Same field + same results → same heats and same standing, every replay.
+//!
+//! # Future: the bump-up variant
+//!
+//! A common variant lets the **top finisher(s) of a lower main bump up** into the next
+//! main (B's winner earns a slot in A, etc.), which turns the independent mains into a
+//! dependent ladder run bottom-up. v1 is deliberately **straight tiered mains** (mains
+//! are independent, emitted together). Bump-up is a future option (it would emit the
+//! lowest main first and seed each higher main from the one below); the trait surface
+//! does not change.
+#![forbid(unsafe_code)]
+
+use std::collections::BTreeMap;
+
+use gridfpv_events::CompetitorRef;
+
+use crate::format::{
+ CompletedHeat, FormatConfig, FormatRegistry, Generator, GeneratorStep, HeatPlan, RankEntry,
+ rank_by, result_ranking,
+};
+
+/// Multi-tier mains over a seeded (qualifying-ranked) field.
+///
+/// Constructed with the seeded field (the recorded [`SeedingOutcome`] already applied,
+/// so the field order *is* the qualifying rank) and a `main_size`. `next` emits the A/B/C
+/// … main heats; `ranking` concatenates their results in tier order. See the module docs.
+///
+/// [`SeedingOutcome`]: crate::format::SeedingOutcome
+pub struct MultiMain {
+ /// The field in qualifying-rank order (best seed first; recorded draw already applied).
+ field: Vec,
+ /// Competitors per main (the A/B/C… tier size). Clamped to a minimum of 1.
+ main_size: usize,
+}
+
+impl MultiMain {
+ /// The format name this registers under.
+ pub const NAME: &'static str = "multi_main";
+
+ /// The default tier size (a standard 4-up main).
+ pub const DEFAULT_MAIN_SIZE: usize = 4;
+
+ /// Build over a `field` in qualifying-rank order with the given `main_size` (clamped
+ /// to a minimum of 1 — a main needs at least one competitor).
+ pub fn new(field: Vec, main_size: usize) -> Self {
+ Self {
+ field,
+ main_size: main_size.max(1),
+ }
+ }
+
+ /// The registry constructor: applies the recorded `seeding` draw to `field` and reads
+ /// the optional `main_size` param (default [`DEFAULT_MAIN_SIZE`](Self::DEFAULT_MAIN_SIZE)).
+ pub fn from_config(config: &FormatConfig) -> Box {
+ let field = config.seeding.apply(&config.field);
+ let main_size = config.param_usize("main_size", Self::DEFAULT_MAIN_SIZE);
+ Box::new(Self::new(field, main_size))
+ }
+
+ /// Register this format under [`NAME`](Self::NAME).
+ pub fn register(registry: &mut FormatRegistry) {
+ registry.register(Self::NAME, Self::from_config);
+ }
+
+ /// The tier letter for main index `index` (0-based): 0 → `A`, 1 → `B`, … For more
+ /// tiers than the alphabet holds (>26 mains, vanishingly unlikely) it falls back to
+ /// the raw index so ids stay unique and deterministic.
+ fn tier_label(index: usize) -> String {
+ if index < 26 {
+ ((b'A' + index as u8) as char).to_string()
+ } else {
+ index.to_string()
+ }
+ }
+
+ /// The heat id for the main at `index`: `main-A`, `main-B`, …
+ fn main_id(index: usize) -> String {
+ format!("main-{}", Self::tier_label(index))
+ }
+
+ /// The tiers as a list of `(heat_id, lineup)` pairs, best tier first.
+ ///
+ /// Slices the seeded field into chunks of `main_size`; the final chunk may be **short**
+ /// (a short last main) when the field does not divide evenly, and a field of `main_size`
+ /// or fewer yields a single A main. Pure and deterministic.
+ fn tiers(&self) -> Vec<(String, Vec)> {
+ self.field
+ .chunks(self.main_size)
+ .enumerate()
+ .map(|(index, chunk)| (Self::main_id(index), chunk.to_vec()))
+ .collect()
+ }
+}
+
+impl Generator for MultiMain {
+ fn next(&mut self, completed: &[CompletedHeat]) -> GeneratorStep {
+ let tiers = self.tiers();
+ // An empty field has no mains to run — it is complete immediately.
+ if tiers.is_empty() {
+ return GeneratorStep::Complete;
+ }
+
+ // The mains are independent, so emit them all at once. Once every main has a
+ // recorded result, the format is complete; until then, (re-)emit the full set —
+ // `next` stays a pure function of the completed history.
+ let scored: std::collections::BTreeSet<&str> =
+ completed.iter().map(|c| c.heat.0.as_str()).collect();
+ let all_scored = tiers.iter().all(|(id, _)| scored.contains(id.as_str()));
+ if all_scored {
+ GeneratorStep::Complete
+ } else {
+ GeneratorStep::Run(
+ tiers
+ .into_iter()
+ .map(|(id, lineup)| HeatPlan::new(id, lineup))
+ .collect(),
+ )
+ }
+ }
+
+ fn ranking(&self, completed: &[CompletedHeat]) -> Vec {
+ let by_id: BTreeMap<&str, &CompletedHeat> =
+ completed.iter().map(|c| (c.heat.0.as_str(), c)).collect();
+
+ // Walk the tiers best-first; each tier occupies its own band, so concatenating the
+ // bands puts every A-main finisher above every B-main finisher, and so on. A tier
+ // whose heat has not come back yet falls back to its seed order (provisional).
+ //
+ // Rows carry a (band, in_band_key) sort key so `rank_by` shares positions only
+ // *within* a tier; the competitor ref is the final deterministic tie-break.
+ let mut rows: Vec<(CompetitorRef, (u32, u32))> = Vec::new();
+ for (band, (id, lineup)) in self.tiers().into_iter().enumerate() {
+ let band = band as u32;
+ match by_id.get(id.as_str()) {
+ Some(heat) => {
+ for entry in result_ranking(&heat.result) {
+ rows.push((entry.competitor, (band, entry.position)));
+ }
+ }
+ None => {
+ // Provisional: this main has not been scored — keep its seed order.
+ for (i, competitor) in lineup.into_iter().enumerate() {
+ rows.push((competitor, (band, i as u32 + 1)));
+ }
+ }
+ }
+ }
+ rank_by(rows)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::format::SeedingOutcome;
+ use crate::scoring::{HeatResult, Metric, Placement};
+ use gridfpv_events::AdapterId;
+ use gridfpv_projection::CompetitorKey;
+
+ const ADAPTER: &str = "demo";
+
+ fn cref(name: &str) -> CompetitorRef {
+ CompetitorRef(name.into())
+ }
+
+ fn field(names: &[&str]) -> Vec {
+ names.iter().map(|n| cref(n)).collect()
+ }
+
+ /// Build a `HeatResult` from `(name, position, laps)` rows.
+ fn result(rows: &[(&str, u32, u32)]) -> HeatResult {
+ HeatResult {
+ places: rows
+ .iter()
+ .map(|(name, position, laps)| Placement {
+ competitor: CompetitorKey {
+ adapter: AdapterId(ADAPTER.into()),
+ competitor: cref(name),
+ },
+ position: *position,
+ laps: *laps,
+ metric: Metric::LastLapAt(None),
+ })
+ .collect(),
+ }
+ }
+
+ fn names(entries: &[RankEntry]) -> Vec {
+ entries.iter().map(|e| e.competitor.0.clone()).collect()
+ }
+
+ /// The lineups of a `Run` step (panics if the step is `Complete`).
+ fn lineups(step: &GeneratorStep) -> Vec<(String, Vec)> {
+ match step {
+ GeneratorStep::Run(heats) => heats
+ .iter()
+ .map(|h| {
+ (
+ h.heat.0.clone(),
+ h.lineup.iter().map(|c| c.0.clone()).collect(),
+ )
+ })
+ .collect(),
+ GeneratorStep::Complete => panic!("expected Run, got Complete"),
+ }
+ }
+
+ // --- Tiering ------------------------------------------------------------
+
+ #[test]
+ fn ten_seed_field_splits_into_a_b_and_a_short_c_main() {
+ // 10 seeds, main_size 4 → A(top 4), B(next 4), C(last 2, short).
+ let mut g = MultiMain::new(
+ field(&["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]),
+ 4,
+ );
+ assert_eq!(
+ lineups(&g.next(&[])),
+ vec![
+ (
+ "main-A".to_string(),
+ vec!["1".into(), "2".into(), "3".into(), "4".into()]
+ ),
+ (
+ "main-B".to_string(),
+ vec!["5".into(), "6".into(), "7".into(), "8".into()]
+ ),
+ ("main-C".to_string(), vec!["9".into(), "10".into()]),
+ ]
+ );
+ }
+
+ #[test]
+ fn field_at_or_below_main_size_is_a_single_a_main() {
+ let mut g = MultiMain::new(field(&["1", "2", "3"]), 4);
+ assert_eq!(
+ lineups(&g.next(&[])),
+ vec![(
+ "main-A".to_string(),
+ vec!["1".into(), "2".into(), "3".into()]
+ )]
+ );
+ }
+
+ #[test]
+ fn field_dividing_evenly_has_no_short_main() {
+ let mut g = MultiMain::new(field(&["1", "2", "3", "4", "5", "6"]), 3);
+ let ls = lineups(&g.next(&[]));
+ assert_eq!(ls.len(), 2);
+ assert_eq!(ls[0].1.len(), 3);
+ assert_eq!(ls[1].1.len(), 3);
+ }
+
+ // --- All mains emitted together -----------------------------------------
+
+ #[test]
+ fn emits_every_main_at_once() {
+ let mut g = MultiMain::new(field(&["1", "2", "3", "4", "5", "6", "7", "8"]), 4);
+ // A and B emitted in a single Run step (the mains are independent).
+ assert_eq!(lineups(&g.next(&[])).len(), 2);
+ }
+
+ // --- Completion ---------------------------------------------------------
+
+ #[test]
+ fn completes_only_after_every_main_is_scored() {
+ let mut g = MultiMain::new(field(&["1", "2", "3", "4", "5", "6"]), 3);
+ // Only the A main scored so far → still running (B outstanding).
+ let a_only = vec![CompletedHeat::new(
+ "main-A",
+ result(&[("1", 1, 6), ("2", 2, 5), ("3", 3, 4)]),
+ )];
+ assert!(matches!(g.next(&a_only), GeneratorStep::Run(_)));
+
+ // Both mains scored → complete.
+ let both = vec![
+ CompletedHeat::new("main-A", result(&[("1", 1, 6), ("2", 2, 5), ("3", 3, 4)])),
+ CompletedHeat::new("main-B", result(&[("4", 1, 6), ("5", 2, 5), ("6", 3, 4)])),
+ ];
+ assert_eq!(g.next(&both), GeneratorStep::Complete);
+ }
+
+ // --- Final ranking: concatenation in tier order -------------------------
+
+ #[test]
+ fn final_ranking_concatenates_a_then_b_then_c() {
+ let mut g = MultiMain::new(
+ field(&["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]),
+ 4,
+ );
+ let completed = vec![
+ // A main: finishing order 2, 1, 4, 3.
+ CompletedHeat::new(
+ "main-A",
+ result(&[("2", 1, 8), ("1", 2, 7), ("4", 3, 6), ("3", 4, 5)]),
+ ),
+ // B main: finishing order 5, 8, 6, 7.
+ CompletedHeat::new(
+ "main-B",
+ result(&[("5", 1, 8), ("8", 2, 7), ("6", 3, 6), ("7", 4, 5)]),
+ ),
+ // C main (short): finishing order 10, 9.
+ CompletedHeat::new("main-C", result(&[("10", 1, 8), ("9", 2, 7)])),
+ ];
+ assert_eq!(g.next(&completed), GeneratorStep::Complete);
+
+ let ranking = g.ranking(&completed);
+ // A main first (in its finishing order), then B, then C — concatenated.
+ assert_eq!(
+ names(&ranking),
+ vec!["2", "1", "4", "3", "5", "8", "6", "7", "10", "9"]
+ );
+ // Positions are 1..=10: the worst A finisher (pos 4) outranks the best B
+ // finisher (pos 5), etc.
+ for (i, entry) in ranking.iter().enumerate() {
+ assert_eq!(entry.position, i as u32 + 1);
+ }
+ }
+
+ #[test]
+ fn b_main_winner_ranks_below_the_a_mains_last() {
+ let g = MultiMain::new(field(&["1", "2", "3", "4", "5", "6", "7", "8"]), 4);
+ let completed = vec![
+ CompletedHeat::new(
+ "main-A",
+ result(&[("1", 1, 8), ("2", 2, 7), ("3", 3, 6), ("4", 4, 5)]),
+ ),
+ CompletedHeat::new(
+ "main-B",
+ result(&[("5", 1, 8), ("6", 2, 7), ("7", 3, 6), ("8", 4, 5)]),
+ ),
+ ];
+ let ranking = g.ranking(&completed);
+ // The A-main's last finisher (4) is 4th overall; the B-main's winner (5) is 5th.
+ let pos = |c: &str| {
+ ranking
+ .iter()
+ .find(|e| e.competitor.0 == c)
+ .unwrap()
+ .position
+ };
+ assert_eq!(pos("4"), 4);
+ assert_eq!(pos("5"), 5);
+ assert!(pos("4") < pos("5"));
+ }
+
+ // --- Provisional ranking ------------------------------------------------
+
+ #[test]
+ fn provisional_ranking_before_any_heat_is_the_seed_order() {
+ let g = MultiMain::new(field(&["1", "2", "3", "4", "5", "6"]), 3);
+ assert_eq!(names(&g.ranking(&[])), vec!["1", "2", "3", "4", "5", "6"]);
+ }
+
+ #[test]
+ fn provisional_ranking_uses_scored_mains_and_seed_order_for_the_rest() {
+ let g = MultiMain::new(field(&["1", "2", "3", "4", "5", "6"]), 3);
+ // Only A scored (re-ordered 3,1,2); B keeps seed order behind it.
+ let a_only = vec![CompletedHeat::new(
+ "main-A",
+ result(&[("3", 1, 8), ("1", 2, 7), ("2", 3, 6)]),
+ )];
+ assert_eq!(
+ names(&g.ranking(&a_only)),
+ vec!["3", "1", "2", "4", "5", "6"]
+ );
+ }
+
+ // --- Determinism --------------------------------------------------------
+
+ #[test]
+ fn next_is_deterministic_for_the_same_history() {
+ let mut g1 = MultiMain::new(field(&["1", "2", "3", "4", "5"]), 2);
+ let mut g2 = MultiMain::new(field(&["1", "2", "3", "4", "5"]), 2);
+ assert_eq!(g1.next(&[]), g2.next(&[]));
+ }
+
+ #[test]
+ fn seeding_draw_reorders_the_tiers_deterministically() {
+ // The qualifying rank arrives as a recorded draw; tiering follows that order.
+ let cfg = FormatConfig::new(field(&["1", "2", "3", "4"]))
+ .with_seeding(SeedingOutcome::drawn(field(&["3", "1", "4", "2"])))
+ .with_param("main_size", "2");
+ let mut g1 = MultiMain::from_config(&cfg);
+ let mut g2 = MultiMain::from_config(&cfg);
+ let s1 = g1.next(&[]);
+ assert_eq!(s1, g2.next(&[]));
+ // Drawn order 3,1,4,2 → A(3,1), B(4,2).
+ assert_eq!(
+ lineups(&s1),
+ vec![
+ ("main-A".to_string(), vec!["3".into(), "1".into()]),
+ ("main-B".to_string(), vec!["4".into(), "2".into()]),
+ ]
+ );
+ }
+
+ // --- Registry -----------------------------------------------------------
+
+ #[test]
+ fn registry_builds_multi_main_with_default_main_size() {
+ let mut registry = FormatRegistry::new();
+ MultiMain::register(&mut registry);
+ assert_eq!(registry.names(), vec!["multi_main"]);
+
+ // Default main_size 4: 5 seeds → A(4), B(1, short).
+ let cfg = FormatConfig::new(field(&["1", "2", "3", "4", "5"]));
+ let mut g = registry
+ .build(MultiMain::NAME, &cfg)
+ .expect("multi_main is registered");
+ let ls = lineups(&g.next(&[]));
+ assert_eq!(ls[0].0, "main-A");
+ assert_eq!(ls[0].1.len(), 4);
+ assert_eq!(ls[1].0, "main-B");
+ assert_eq!(ls[1].1, vec!["5".to_string()]);
+ }
+
+ #[test]
+ fn registry_reads_the_main_size_param() {
+ let mut registry = FormatRegistry::new();
+ MultiMain::register(&mut registry);
+ let cfg =
+ FormatConfig::new(field(&["1", "2", "3", "4", "5", "6"])).with_param("main_size", "3");
+ let mut g = registry.build(MultiMain::NAME, &cfg).unwrap();
+ let ls = lineups(&g.next(&[]));
+ assert_eq!(ls.len(), 2);
+ assert_eq!(ls[0].1.len(), 3);
+ assert_eq!(ls[1].1.len(), 3);
+ }
+}
diff --git a/crates/engine/tests/multi_main_live.rs b/crates/engine/tests/multi_main_live.rs
new file mode 100644
index 0000000..5547805
--- /dev/null
+++ b/crates/engine/tests/multi_main_live.rs
@@ -0,0 +1,207 @@
+//! Multi-tier mains end-to-end test (#13) — real tiered mains over real mock-RH heats.
+//!
+//! Drives a small multi-main final (6 pilots, `main_size` 3 → an A main and a B main)
+//! where every main is a **real** dockerized RotorHazard heat run through the shared
+//! [`common::run_mock_heat`] harness. The [`MultiMain`] generator emits both mains in one
+//! [`GeneratorStep::Run`]; for each we seat the lineup onto RotorHazard nodes (a busy
+//! continuously-lapping stream for the intended in-main winner, DNF streams for the rest),
+//! run the heat, score it with [`score_events`], translate the node placements back onto
+//! the main's competitor refs, and feed every [`CompletedHeat`] back. We assert the format
+//! terminates and the final ranking orders the **whole A main above the whole B main** —
+//! the defining property of tiered mains.
+//!
+//! As with the other format e2es, the mock reads its CSV continuously (lap timing is not
+//! controllable), so this is **structural / tolerant**: we rely only on "the busy node
+//! out-laps the DNF nodes", never on exact lap times.
+//!
+//! For RH the canonical pass `at` is **ms since race start**, so the timed clock starts at
+//! zero (`race_start = SourceTime::from_micros(0)`).
+//!
+//! Local-only class (needs Docker). DISTINCT port 5044. Run:
+//!
+//! ```sh
+//! cargo test -p gridfpv-engine --features live --test multi_main_live -- --ignored --nocapture
+//! ```
+#![cfg(feature = "live")]
+
+mod common;
+
+use common::run_mock_heat;
+
+use gridfpv_engine::format::{CompletedHeat, Generator, GeneratorStep, HeatPlan};
+use gridfpv_engine::multi_main::MultiMain;
+use gridfpv_engine::scoring::Placement;
+use gridfpv_engine::scoring::{HeatResult, WinCondition, score_events};
+use gridfpv_events::{CompetitorRef, SourceTime};
+use gridfpv_projection::CompetitorKey;
+use gridfpv_testkit::{NodeCsv, node_csv, plan_csv, scenarios};
+
+/// DISTINCT port for the multi-main e2e (heat e2e 5032, scoring 5033, single-elim 5037).
+const PORT: u16 = 5044;
+
+/// A continuously-lapping stream for the main's intended winner.
+fn busy_stream() -> String {
+ node_csv(&NodeCsv {
+ ticks_per_lap: 2,
+ peak_rssi: 180,
+ baseline_rssi: 70,
+ })
+}
+
+/// A drop-out stream: a couple of early laps then flat — far fewer laps than the busy one.
+fn dnf_stream() -> String {
+ plan_csv(&scenarios::dnf(2, 6))
+}
+
+/// The seat node index behind a `node-{i}` competitor ref, if it has that shape.
+fn node_index(key: &CompetitorKey) -> Option {
+ key.competitor.0.strip_prefix("node-")?.parse().ok()
+}
+
+/// Rebuild a placement under the main's competitor `as_ref`, preserving position/laps.
+fn remap(place: &Placement, as_ref: &CompetitorRef) -> Placement {
+ Placement {
+ competitor: CompetitorKey {
+ adapter: place.competitor.adapter.clone(),
+ competitor: as_ref.clone(),
+ },
+ position: place.position,
+ laps: place.laps,
+ metric: place.metric,
+ }
+}
+
+/// Run one main against real RotorHazard and return its scored [`HeatResult`] expressed in
+/// the main's own competitor refs. `winner` (seated on node 0, busy stream) is intended to
+/// win; everyone else is a DNF node. Node placements are translated back by lineup position.
+fn run_main_heat(plan: &HeatPlan, winner: &CompetitorRef) -> CompletedHeat {
+ let mut ordered: Vec<&CompetitorRef> = Vec::with_capacity(plan.lineup.len());
+ ordered.push(winner);
+ for c in &plan.lineup {
+ if c != winner {
+ ordered.push(c);
+ }
+ }
+
+ let scenario: Vec<(usize, String)> = ordered
+ .iter()
+ .enumerate()
+ .map(|(node, _)| {
+ let stream = if node == 0 {
+ busy_stream()
+ } else {
+ dnf_stream()
+ };
+ (node, stream)
+ })
+ .collect();
+
+ let log = run_mock_heat(PORT, &plan.heat.0, &scenario);
+ let race_start = SourceTime::from_micros(0);
+ let scored = score_events(
+ &log,
+ WinCondition::Timed {
+ window_micros: 10 * 60 * 1_000_000,
+ },
+ race_start,
+ );
+
+ let mut places: Vec = Vec::new();
+ let mut seen: Vec = vec![false; ordered.len()];
+ for place in &scored.places {
+ if let Some(node) = node_index(&place.competitor) {
+ if node < ordered.len() {
+ seen[node] = true;
+ places.push(remap(place, ordered[node]));
+ }
+ }
+ }
+ let mut next_pos = places.len() as u32 + 1;
+ for (node, present) in seen.iter().enumerate() {
+ if !present {
+ places.push(Placement {
+ competitor: CompetitorKey {
+ adapter: scored
+ .places
+ .first()
+ .map(|p| p.competitor.adapter.clone())
+ .unwrap_or_else(|| gridfpv_events::AdapterId("rotorhazard".into())),
+ competitor: ordered[node].clone(),
+ },
+ position: next_pos,
+ laps: 0,
+ metric: gridfpv_engine::scoring::Metric::LastLapAt(None),
+ });
+ next_pos += 1;
+ }
+ }
+
+ CompletedHeat::new(plan.heat.0.clone(), HeatResult { places })
+}
+
+#[test]
+#[ignore = "requires Docker (spins up dockerized RotorHazard and drives full heats)"]
+fn two_tier_mains_run_over_real_heats_and_rank_a_above_b() {
+ // 6 qualifying-ranked pilots, main_size 3 → A main (1,2,3) and B main (4,5,6).
+ let field: Vec = ["1", "2", "3", "4", "5", "6"]
+ .iter()
+ .map(|n| CompetitorRef(n.to_string()))
+ .collect();
+ let mut generator = MultiMain::new(field.clone(), 3);
+
+ let a_field: Vec<&str> = vec!["1", "2", "3"];
+ let b_field: Vec<&str> = vec!["4", "5", "6"];
+
+ let mut completed: Vec = Vec::new();
+ let mut steps = 0;
+ while let GeneratorStep::Run(heats) = generator.next(&completed) {
+ steps += 1;
+ assert!(
+ steps < 4,
+ "tiered mains should converge in a single Run step"
+ );
+ assert!(!heats.is_empty(), "a Run step must carry at least one heat");
+ for plan in &heats {
+ // Top seed present in each main takes the busy stream (smallest numeric ref).
+ let winner = plan
+ .lineup
+ .iter()
+ .min_by_key(|c| c.0.parse::().unwrap_or(u32::MAX))
+ .cloned()
+ .expect("non-empty lineup");
+ eprintln!(
+ "multi-main e2e: heat {} lineup {:?} intended winner {}",
+ plan.heat.0,
+ plan.lineup.iter().map(|c| &c.0).collect::>(),
+ winner.0
+ );
+ completed.push(run_main_heat(plan, &winner));
+ }
+ }
+
+ // The format terminated; every pilot appears in the final ranking.
+ let ranking = generator.ranking(&completed);
+ assert_eq!(ranking.len(), 6, "every pilot should appear in the ranking");
+
+ // The defining property: the whole A main outranks the whole B main. The worst
+ // A-main position is strictly better (smaller) than the best B-main position.
+ let pos = |c: &str| {
+ ranking
+ .iter()
+ .find(|e| e.competitor.0 == c)
+ .unwrap_or_else(|| panic!("missing {c} in ranking"))
+ .position
+ };
+ let worst_a = a_field.iter().map(|c| pos(c)).max().unwrap();
+ let best_b = b_field.iter().map(|c| pos(c)).min().unwrap();
+ assert!(
+ worst_a < best_b,
+ "every A-main finisher must rank above every B-main finisher \
+ (worst A pos {worst_a} vs best B pos {best_b})"
+ );
+
+ eprintln!(
+ "multi-main e2e: final order {:?}",
+ ranking.iter().map(|e| &e.competitor.0).collect::>()
+ );
+}
From 7a49df6985691e2f09bbb9d08df0017804bb65a8 Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 16:58:25 +0000
Subject: [PATCH 035/362] Add round-robin format generator
Implement `RoundRobin`, a rounds-based `format::Generator` sibling to
`timed_qual`: each round partitions the field into heats of `heat_size`
and rotates the partition (cyclic rotation by the round index) so pilots
meet a varied spread of opponents, then aggregates each pilot's results
across all their heats into one tie-aware ranking.
- `next` emits a round's worth of heats (`rr-r{n}-h{i}`) until `rounds`
rounds are done, then `Complete`; pure function of completed history.
- `ranking` aggregates by finishing-position points (default) or total
laps, "more is better", deterministically tie-broken via `rank_by`.
- Rotation by `r mod field_len` re-pairs pilots every consecutive round
(heat_size >= 2); indivisible fields leave a short final heat; an empty
field / heat_size 0 are handled.
- `from_config` reads `rounds`/`heat_size`/`metric`; `register` under
"round_robin".
- 22 table tests + a mock-RH e2e (`round_robin_live`, port 5043).
`cargo xtask ci` passes; the live e2e passes under Docker.
Part of #13 (round-robin generator).
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
crates/engine/src/round_robin.rs | 696 +++++++++++++++++++++++-
crates/engine/tests/round_robin_live.rs | 203 +++++++
2 files changed, 898 insertions(+), 1 deletion(-)
create mode 100644 crates/engine/tests/round_robin_live.rs
diff --git a/crates/engine/src/round_robin.rs b/crates/engine/src/round_robin.rs
index 13f30f6..3ffd284 100644
--- a/crates/engine/src/round_robin.rs
+++ b/crates/engine/src/round_robin.rs
@@ -1 +1,695 @@
-//! round_robin format generator — implements format::Generator (filled by the agent).
+//! `round_robin` — a rounds-based [`Generator`] that **rotates** the field through a
+//! fixed number of heats so pilots face a varied spread of opponents
+//! (race-engine.html §3).
+//!
+//! # What round-robin is here
+//!
+//! Where [`crate::timed_qual`] flies the *whole* field together every round, a
+//! round-robin **partitions** the field into heats of `heat_size` each round and
+//! **rotates** the partition across `rounds` rounds, so a pilot meets a *different
+//! mix* of opponents from one round to the next. A pilot's standing aggregates their
+//! results across every heat they flew — by finishing-position **points** (default) or
+//! by **total laps** — into one tie-aware ranking.
+//!
+//! This is the sibling of `timed_qual`: same "emit a round, score it, feed it back,
+//! repeat until `rounds` are done" shape, but the per-round lineup is a *rotated
+//! partition* rather than the whole field, and the aggregation **sums** across rounds
+//! rather than taking a single best flight.
+//!
+//! # The rotation schedule (deterministic, balanced)
+//!
+//! For round `r` (0-based) the field is **cyclically rotated left** by `r` positions
+//! and then sliced into consecutive heats of `heat_size`:
+//!
+//! ```text
+//! offset(r) = r mod field_len
+//! rotated = field[offset..] ++ field[..offset]
+//! heats = rotated chunked into runs of `heat_size`
+//! ```
+//!
+//! Each round shifts the chunk boundaries by one pilot, so the partition genuinely
+//! re-mixes from one round to the next: a partition repeats only once `r` has advanced
+//! a full `heat_size` (the boundaries realign), so for any `heat_size ≥ 2` **every pair
+//! of consecutive rounds re-pairs pilots** and a pilot meets a fresh spread of
+//! opponents as the rounds progress. The schedule is a pure function of `(r,
+//! field_len)` — no clock, no RNG — so it replays identically (RE §6). (With
+//! `heat_size = 1` every pilot is alone in their own heat, so the partition is trivially
+//! the same every round — there are no opponents to vary.)
+//!
+//! # Indivisible fields
+//!
+//! When `field_len` is not a multiple of `heat_size`, the chunking leaves a **short
+//! final heat** carrying the remainder (e.g. 7 pilots at `heat_size` 3 → heats of 3,
+//! 3, 1). The split is purely positional on the rotated order, so it is deterministic
+//! and the short heat lands on different pilots each round as the rotation advances. A
+//! `heat_size` of 0 is treated as 1 (every pilot in their own heat) so the format
+//! always makes progress.
+//!
+//! # The aggregation metric (config-selected, points by default)
+//!
+//! Each heat is *scored* by the format's [`crate::scoring::WinCondition`] (the e2e
+//! drives that); the cross-heat **aggregation** ranks pilots by a [`RrMetric`]:
+//!
+//! - [`RrMetric::Points`] (**default**) — finishing-position points, *lower position =
+//! more points*. A pilot in a heat of `k` scores `k - position + 1` points for that
+//! heat (the winner of a `k`-pilot heat gets `k`, last gets `1`); points sum across
+//! all the pilot's heats and **more points = better**. The classic round-robin
+//! standing: consistently finishing near the front wins.
+//! - [`RrMetric::TotalLaps`] — the sum of laps banked across all the pilot's heats;
+//! **more laps = better**.
+//!
+//! Either way the aggregate is ranked into a tie-aware order with [`rank_by`], with the
+//! competitor ref as the final, total, deterministic tie-break (RE §6).
+//!
+//! [`rank_by`]: crate::format::rank_by
+#![forbid(unsafe_code)]
+
+use std::collections::BTreeMap;
+
+use gridfpv_events::CompetitorRef;
+
+use crate::format::{
+ CompletedHeat, FormatConfig, FormatRegistry, Generator, GeneratorStep, HeatPlan, RankEntry,
+ rank_by,
+};
+
+/// Which aggregate the cross-heat ranking ranks pilots by. See the module docs.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum RrMetric {
+ /// Finishing-position points summed across heats (more = better). The default.
+ #[default]
+ Points,
+ /// Total laps banked across heats (more = better).
+ TotalLaps,
+}
+
+impl RrMetric {
+ /// Parse a `metric` config value, defaulting to [`RrMetric::Points`] for an absent
+ /// or unrecognised value (the documented default).
+ fn parse(value: Option<&str>) -> Self {
+ match value {
+ Some("total-laps") | Some("laps") => RrMetric::TotalLaps,
+ // "points", anything else, or absent → the default points standing.
+ _ => RrMetric::Points,
+ }
+ }
+}
+
+/// A round-robin generator: over `rounds` rounds the field is partitioned into heats of
+/// `heat_size`, the partition **rotating** each round so pilots face a varied spread of
+/// opponents, then each pilot's heats are aggregated into one ranking (RE §3).
+///
+/// Construct with [`RoundRobin::new`] or, via the registry, [`from_config`] under the
+/// name [`NAME`](Self::NAME). `next` emits one round's worth of heats at a time until
+/// `rounds` rounds are completed, then [`GeneratorStep::Complete`]; `ranking` aggregates
+/// across every heat under the configured [`RrMetric`].
+///
+/// [`from_config`]: RoundRobin::from_config
+pub struct RoundRobin {
+ /// The field, in seed/draw order (any recorded outcome already applied). The base
+ /// order the rotation schedule rotates and the final ranking tie-break.
+ field: Vec,
+ /// How many rounds the field flies before the format completes.
+ rounds: usize,
+ /// How many pilots share a heat within a round (1 or more; 0 is treated as 1).
+ heat_size: usize,
+ /// Which aggregate the cross-heat ranking ranks by.
+ metric: RrMetric,
+}
+
+impl RoundRobin {
+ /// The format name this registers under.
+ pub const NAME: &'static str = "round_robin";
+
+ /// The default number of rounds when `rounds` is not configured.
+ pub const DEFAULT_ROUNDS: usize = 3;
+
+ /// The default heat size when `heat_size` is not configured.
+ pub const DEFAULT_HEAT_SIZE: usize = 4;
+
+ /// Build over `field` for `rounds` rounds of heats of `heat_size`, aggregated by
+ /// `metric`. The field is taken in the given order (apply any
+ /// [`crate::format::SeedingOutcome`] before calling, as
+ /// [`from_config`](Self::from_config) does).
+ pub fn new(
+ field: Vec,
+ rounds: usize,
+ heat_size: usize,
+ metric: RrMetric,
+ ) -> Self {
+ Self {
+ field,
+ rounds,
+ // Never let heat_size be 0, or chunking would loop forever / make no
+ // progress; one pilot per heat is the sensible floor.
+ heat_size: heat_size.max(1),
+ metric,
+ }
+ }
+
+ /// The registry constructor: applies the recorded `seeding` draw to `field`, reads
+ /// `rounds` (default [`DEFAULT_ROUNDS`](Self::DEFAULT_ROUNDS)), `heat_size` (default
+ /// [`DEFAULT_HEAT_SIZE`](Self::DEFAULT_HEAT_SIZE)) and the `metric` param (default
+ /// [`RrMetric::Points`]).
+ pub fn from_config(config: &FormatConfig) -> Box {
+ let field = config.seeding.apply(&config.field);
+ let rounds = config.param_usize("rounds", Self::DEFAULT_ROUNDS);
+ let heat_size = config.param_usize("heat_size", Self::DEFAULT_HEAT_SIZE);
+ let metric = RrMetric::parse(config.params.get("metric").map(String::as_str));
+ Box::new(Self::new(field, rounds, heat_size, metric))
+ }
+
+ /// Register this format under [`NAME`](Self::NAME).
+ pub fn register(registry: &mut FormatRegistry) {
+ registry.register(Self::NAME, Self::from_config);
+ }
+
+ /// Heat id for heat `i` (1-based) of round `n` (1-based) — `rr-r1-h1`, `rr-r1-h2`,
+ /// `rr-r2-h1`, …
+ fn heat_id(round: usize, heat: usize) -> String {
+ format!("rr-r{round}-h{heat}")
+ }
+
+ /// The cyclic rotation offset for round `r` (0-based). See the module docs: rotating
+ /// by `r` shifts every chunk boundary by one pilot each round so the partition
+ /// re-mixes opponents from round to round instead of repeating.
+ fn offset(&self, round_index: usize) -> usize {
+ let len = self.field.len();
+ if len == 0 {
+ return 0;
+ }
+ round_index % len
+ }
+
+ /// The field rotated left by [`offset`](Self::offset) for round `r` (0-based).
+ fn rotated(&self, round_index: usize) -> Vec {
+ let len = self.field.len();
+ if len == 0 {
+ return Vec::new();
+ }
+ let offset = self.offset(round_index);
+ let mut out = Vec::with_capacity(len);
+ out.extend_from_slice(&self.field[offset..]);
+ out.extend_from_slice(&self.field[..offset]);
+ out
+ }
+
+ /// The heat plans for round `n` (1-based): the field rotated for this round, sliced
+ /// into consecutive heats of `heat_size` (the last possibly short).
+ fn round_heats(&self, round: usize) -> Vec {
+ let rotated = self.rotated(round - 1);
+ rotated
+ .chunks(self.heat_size)
+ .enumerate()
+ .map(|(heat_index, chunk)| {
+ HeatPlan::new(Self::heat_id(round, heat_index + 1), chunk.to_vec())
+ })
+ .collect()
+ }
+
+ /// How many heats one round emits (the same every round — the field partitioned).
+ fn heats_per_round(&self) -> usize {
+ let len = self.field.len();
+ if len == 0 {
+ 0
+ } else {
+ len.div_ceil(self.heat_size)
+ }
+ }
+}
+
+impl Generator for RoundRobin {
+ fn next(&mut self, completed: &[CompletedHeat]) -> GeneratorStep {
+ // Each round emits `heats_per_round` heats; the completed count divided by that
+ // is the number of rounds fully run. Emit the next round while any remain,
+ // otherwise the format is complete. Pure function of (completed.len(), schedule)
+ // — no clock, no RNG.
+ let per_round = self.heats_per_round();
+ if per_round == 0 {
+ // Empty field: nothing to ever run.
+ return GeneratorStep::Complete;
+ }
+ let rounds_run = completed.len() / per_round;
+ if rounds_run < self.rounds {
+ GeneratorStep::Run(self.round_heats(rounds_run + 1))
+ } else {
+ GeneratorStep::Complete
+ }
+ }
+
+ fn ranking(&self, completed: &[CompletedHeat]) -> Vec {
+ // Aggregate each competitor across every heat they flew under the configured
+ // metric. Seed every field member at 0 so a no-show still appears in the
+ // ranking. The accumulated score is "more is better", so we negate it into a
+ // smaller-is-better rank key for `rank_by`.
+ let mut totals: BTreeMap = BTreeMap::new();
+ for competitor in &self.field {
+ totals.entry(competitor.clone()).or_insert(0);
+ }
+
+ for heat in completed {
+ let heat_size = heat.result.places.len() as i64;
+ for place in &heat.result.places {
+ let gain = match self.metric {
+ // Finishing-position points: a heat of `k` awards `k` to the winner
+ // (position 1) down to `1` for last (position k). Tied pilots share a
+ // position and so are awarded the same points.
+ RrMetric::Points => (heat_size - place.position as i64 + 1).max(0),
+ // Total laps banked in this heat.
+ RrMetric::TotalLaps => place.laps as i64,
+ };
+ *totals
+ .entry(place.competitor.competitor.clone())
+ .or_insert(0) += gain;
+ }
+ }
+
+ // Rank key: negate the accumulated score so more points / laps = smaller key =
+ // better; `rank_by` adds the competitor ref as the final, total tie-break.
+ let rows: Vec<(CompetitorRef, i64)> = totals
+ .into_iter()
+ .map(|(competitor, score)| (competitor, -score))
+ .collect();
+ rank_by(rows)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::scoring::{HeatResult, Metric, Placement};
+ use gridfpv_events::AdapterId;
+ use gridfpv_projection::CompetitorKey;
+ use std::collections::HashSet;
+
+ const ADAPTER: &str = "demo";
+
+ fn cref(name: &str) -> CompetitorRef {
+ CompetitorRef(name.into())
+ }
+
+ fn field(names: &[&str]) -> Vec {
+ names.iter().map(|n| cref(n)).collect()
+ }
+
+ fn names(entries: &[RankEntry]) -> Vec {
+ entries.iter().map(|e| e.competitor.0.clone()).collect()
+ }
+
+ /// Build a `HeatResult` from `(name, position, laps)` rows.
+ fn result(rows: &[(&str, u32, u32)]) -> HeatResult {
+ HeatResult {
+ places: rows
+ .iter()
+ .map(|(name, position, laps)| Placement {
+ competitor: CompetitorKey {
+ adapter: AdapterId(ADAPTER.into()),
+ competitor: cref(name),
+ },
+ position: *position,
+ laps: *laps,
+ metric: Metric::LastLapAt(None),
+ })
+ .collect(),
+ }
+ }
+
+ /// The set of competitor names a heat plan lines up.
+ fn lineup_names(plan: &HeatPlan) -> Vec {
+ plan.lineup.iter().map(|c| c.0.clone()).collect()
+ }
+
+ /// Extract the `Run` plans from a step, panicking if it completed.
+ fn run(step: GeneratorStep) -> Vec {
+ match step {
+ GeneratorStep::Run(plans) => plans,
+ GeneratorStep::Complete => panic!("expected Run, got Complete"),
+ }
+ }
+
+ /// All unordered opponent pairs a pilot shares a heat with, across a round's heats.
+ fn pairs(plans: &[HeatPlan]) -> HashSet<(String, String)> {
+ let mut out = HashSet::new();
+ for plan in plans {
+ let pilots = lineup_names(plan);
+ for i in 0..pilots.len() {
+ for j in (i + 1)..pilots.len() {
+ let (a, b) = (pilots[i].clone(), pilots[j].clone());
+ out.insert(if a < b { (a, b) } else { (b, a) });
+ }
+ }
+ }
+ out
+ }
+
+ // --- next(): partitions and rotation -------------------------------------
+
+ #[test]
+ fn round_one_partitions_the_field_into_heats_of_heat_size() {
+ let mut g = RoundRobin::new(field(&["A", "B", "C", "D"]), 3, 2, RrMetric::Points);
+ let plans = run(g.next(&[]));
+ // 4 pilots / heat_size 2 → two heats.
+ assert_eq!(plans.len(), 2);
+ assert_eq!(plans[0].heat.0, "rr-r1-h1");
+ assert_eq!(plans[1].heat.0, "rr-r1-h2");
+ assert_eq!(lineup_names(&plans[0]), vec!["A", "B"]);
+ assert_eq!(lineup_names(&plans[1]), vec!["C", "D"]);
+ }
+
+ #[test]
+ fn every_round_covers_the_whole_field_exactly_once() {
+ let g = RoundRobin::new(
+ field(&["A", "B", "C", "D", "E", "F"]),
+ 4,
+ 2,
+ RrMetric::Points,
+ );
+ for round in 1..=4 {
+ let plans = g.round_heats(round);
+ let mut seen: Vec = plans.iter().flat_map(lineup_names).collect();
+ seen.sort();
+ assert_eq!(
+ seen,
+ vec!["A", "B", "C", "D", "E", "F"],
+ "round {round} must cover the whole field exactly once"
+ );
+ }
+ }
+
+ #[test]
+ fn the_partition_rotates_so_pilots_meet_different_opponents() {
+ let g = RoundRobin::new(
+ field(&["A", "B", "C", "D", "E", "F"]),
+ 3,
+ 2,
+ RrMetric::Points,
+ );
+ // Each consecutive round re-pairs pilots (the partition rotates by one pilot).
+ let p1 = pairs(&g.round_heats(1));
+ let p2 = pairs(&g.round_heats(2));
+ let p3 = pairs(&g.round_heats(3));
+ assert_ne!(p1, p2, "round 2 must re-pair pilots vs round 1");
+ assert_ne!(p2, p3, "round 3 must re-pair pilots vs round 2");
+ }
+
+ #[test]
+ fn rotation_re_pairs_a_specific_pilot_across_rounds() {
+ // A concrete opponent check: A's heat-mates change between round 1 and round 2.
+ let g = RoundRobin::new(
+ field(&["A", "B", "C", "D", "E", "F"]),
+ 2,
+ 3,
+ RrMetric::Points,
+ );
+ let mates = |plans: &[HeatPlan]| -> Vec {
+ let heat = plans
+ .iter()
+ .find(|p| lineup_names(p).contains(&"A".to_string()))
+ .unwrap();
+ let mut m: Vec = lineup_names(heat)
+ .into_iter()
+ .filter(|n| n != "A")
+ .collect();
+ m.sort();
+ m
+ };
+ assert_ne!(
+ mates(&g.round_heats(1)),
+ mates(&g.round_heats(2)),
+ "A should meet a different set of opponents in round 2"
+ );
+ }
+
+ #[test]
+ fn indivisible_field_leaves_a_short_final_heat() {
+ // 7 pilots at heat_size 3 → heats of 3, 3, 1 (the remainder in a short heat).
+ let mut g = RoundRobin::new(
+ field(&["A", "B", "C", "D", "E", "F", "G"]),
+ 2,
+ 3,
+ RrMetric::Points,
+ );
+ let plans = run(g.next(&[]));
+ assert_eq!(plans.len(), 3);
+ assert_eq!(plans[0].lineup.len(), 3);
+ assert_eq!(plans[1].lineup.len(), 3);
+ assert_eq!(plans[2].lineup.len(), 1, "the remainder rides a short heat");
+ }
+
+ #[test]
+ fn heat_size_larger_than_field_is_one_heat() {
+ let mut g = RoundRobin::new(field(&["A", "B", "C"]), 1, 8, RrMetric::Points);
+ let plans = run(g.next(&[]));
+ assert_eq!(plans.len(), 1);
+ assert_eq!(lineup_names(&plans[0]), vec!["A", "B", "C"]);
+ }
+
+ // --- next(): completion ---------------------------------------------------
+
+ #[test]
+ fn completes_after_the_configured_rounds() {
+ // 4 pilots, heat_size 2 → 2 heats/round, 2 rounds → 4 heats total.
+ let mut g = RoundRobin::new(field(&["A", "B", "C", "D"]), 2, 2, RrMetric::Points);
+ let mut history: Vec = Vec::new();
+ for round in 1..=2 {
+ let plans = run(g.next(&history));
+ assert_eq!(plans.len(), 2, "round {round} emits two heats");
+ for plan in plans {
+ history.push(CompletedHeat::new(
+ plan.heat.0.clone(),
+ result(&[(&plan.lineup[0].0, 1, 3), (&plan.lineup[1].0, 2, 2)]),
+ ));
+ }
+ }
+ // After both rounds (4 heats), the format completes.
+ assert_eq!(g.next(&history), GeneratorStep::Complete);
+ }
+
+ #[test]
+ fn emits_round_two_after_round_one_completes() {
+ let mut g = RoundRobin::new(field(&["A", "B", "C", "D"]), 3, 2, RrMetric::Points);
+ // Round 1's two heats come back.
+ let r1 = run(g.next(&[]));
+ let history: Vec = r1
+ .iter()
+ .map(|p| {
+ CompletedHeat::new(
+ p.heat.0.clone(),
+ result(&[(&p.lineup[0].0, 1, 3), (&p.lineup[1].0, 2, 2)]),
+ )
+ })
+ .collect();
+ let r2 = run(g.next(&history));
+ assert_eq!(r2[0].heat.0, "rr-r2-h1");
+ assert_eq!(r2[1].heat.0, "rr-r2-h2");
+ }
+
+ #[test]
+ fn empty_field_completes_immediately() {
+ let mut g = RoundRobin::new(field(&[]), 3, 2, RrMetric::Points);
+ assert_eq!(g.next(&[]), GeneratorStep::Complete);
+ }
+
+ // --- next(): determinism --------------------------------------------------
+
+ #[test]
+ fn next_is_deterministic_for_the_same_history() {
+ let mut g1 = RoundRobin::new(field(&["A", "B", "C", "D"]), 3, 2, RrMetric::Points);
+ let mut g2 = RoundRobin::new(field(&["A", "B", "C", "D"]), 3, 2, RrMetric::Points);
+ assert_eq!(g1.next(&[]), g2.next(&[]));
+ }
+
+ // --- ranking(): points aggregation ---------------------------------------
+
+ #[test]
+ fn ranking_by_points_rewards_winning_more_heats() {
+ // 4 pilots, heat_size 2, 2 rounds. A wins both its heats; D loses both.
+ // Points in a heat of 2: winner 2, loser 1.
+ let g = RoundRobin::new(field(&["A", "B", "C", "D"]), 2, 2, RrMetric::Points);
+ // Round 1: r1-h1 = A,B (A wins); r1-h2 = C,D (C wins).
+ // Round 2 (rotated): use whatever heats, but make A win again and D lose again.
+ let completed = vec![
+ CompletedHeat::new("rr-r1-h1", result(&[("A", 1, 5), ("B", 2, 4)])),
+ CompletedHeat::new("rr-r1-h2", result(&[("C", 1, 5), ("D", 2, 4)])),
+ // Round 2 pairings differ, but A still wins and D still loses.
+ CompletedHeat::new("rr-r2-h1", result(&[("A", 1, 5), ("D", 2, 4)])),
+ CompletedHeat::new("rr-r2-h2", result(&[("C", 1, 5), ("B", 2, 4)])),
+ ];
+ // Points: A = 2+2 = 4; C = 2+2 = 4; B = 1+1 = 2; D = 1+1 = 2.
+ // A and C tie (4), B and D tie (2). Tie-break is competitor ref.
+ let ranking = g.ranking(&completed);
+ assert_eq!(names(&ranking), vec!["A", "C", "B", "D"]);
+ assert_eq!(ranking[0].position, 1);
+ assert_eq!(ranking[1].position, 1, "A and C tie on 4 points");
+ assert_eq!(ranking[2].position, 3);
+ assert_eq!(ranking[3].position, 3, "B and D tie on 2 points");
+ }
+
+ #[test]
+ fn ranking_a_consistent_winner_tops_the_table() {
+ // A wins every heat outright → most points → first.
+ let g = RoundRobin::new(field(&["A", "B", "C", "D"]), 2, 2, RrMetric::Points);
+ let completed = vec![
+ CompletedHeat::new("rr-r1-h1", result(&[("A", 1, 5), ("B", 2, 4)])),
+ CompletedHeat::new("rr-r1-h2", result(&[("C", 1, 5), ("D", 2, 4)])),
+ CompletedHeat::new("rr-r2-h1", result(&[("A", 1, 5), ("C", 2, 4)])),
+ CompletedHeat::new("rr-r2-h2", result(&[("B", 1, 5), ("D", 2, 4)])),
+ ];
+ // A: 2+2=4; B: 1+2=3; C: 2+1=3; D: 1+1=2 → A, then B/C tie, then D.
+ let ranking = g.ranking(&completed);
+ assert_eq!(ranking[0].competitor, cref("A"));
+ assert_eq!(ranking[0].position, 1);
+ assert_eq!(ranking[3].competitor, cref("D"));
+ assert_eq!(ranking[3].position, 4);
+ }
+
+ #[test]
+ fn ranking_points_handle_heats_of_different_sizes() {
+ // A wins a heat of 3 (3 points); B wins a heat of 1 (1 point, alone). A ahead.
+ let g = RoundRobin::new(field(&["A", "B", "C", "D"]), 1, 3, RrMetric::Points);
+ let completed = vec![
+ CompletedHeat::new("rr-r1-h1", result(&[("A", 1, 5), ("C", 2, 4), ("D", 3, 3)])),
+ CompletedHeat::new("rr-r1-h2", result(&[("B", 1, 6)])),
+ ];
+ // Points: A=3, C=2, D=1, B=1. A first; B and D tie on 1.
+ let ranking = g.ranking(&completed);
+ assert_eq!(ranking[0].competitor, cref("A"));
+ assert_eq!(ranking[0].position, 1);
+ }
+
+ #[test]
+ fn ranking_by_total_laps_sums_across_heats() {
+ let g = RoundRobin::new(field(&["A", "B", "C", "D"]), 2, 2, RrMetric::TotalLaps);
+ let completed = vec![
+ CompletedHeat::new("rr-r1-h1", result(&[("A", 1, 5), ("B", 2, 3)])),
+ CompletedHeat::new("rr-r1-h2", result(&[("C", 1, 4), ("D", 2, 2)])),
+ CompletedHeat::new("rr-r2-h1", result(&[("A", 1, 6), ("C", 2, 4)])),
+ CompletedHeat::new("rr-r2-h2", result(&[("B", 1, 5), ("D", 2, 1)])),
+ ];
+ // Total laps: A=5+6=11; B=3+5=8; C=4+4=8; D=2+1=3.
+ // A first, B/C tie on 8, D last.
+ let ranking = g.ranking(&completed);
+ assert_eq!(ranking[0].competitor, cref("A"));
+ assert_eq!(ranking[0].position, 1);
+ assert_eq!(ranking[1].position, 2);
+ assert_eq!(ranking[2].position, 2, "B and C tie on 8 laps");
+ assert_eq!(ranking[3].competitor, cref("D"));
+ assert_eq!(ranking[3].position, 4);
+ }
+
+ #[test]
+ fn ranking_before_any_heat_lists_the_whole_field_tied() {
+ // Provisional ranking with no history: every field member appears, all tied at 0.
+ let g = RoundRobin::new(field(&["B", "A", "C"]), 3, 2, RrMetric::Points);
+ let ranking = g.ranking(&[]);
+ assert_eq!(names(&ranking), vec!["A", "B", "C"]);
+ assert!(ranking.iter().all(|e| e.position == 1));
+ }
+
+ #[test]
+ fn ranking_is_deterministic_across_runs() {
+ let g = RoundRobin::new(field(&["A", "B", "C", "D"]), 2, 2, RrMetric::Points);
+ let completed = vec![
+ CompletedHeat::new("rr-r1-h1", result(&[("A", 1, 5), ("B", 2, 4)])),
+ CompletedHeat::new("rr-r1-h2", result(&[("C", 1, 5), ("D", 2, 4)])),
+ ];
+ assert_eq!(g.ranking(&completed), g.ranking(&completed));
+ }
+
+ // --- full loop ------------------------------------------------------------
+
+ #[test]
+ fn full_two_round_loop_terminates_with_a_points_ranking() {
+ let mut g = RoundRobin::new(field(&["A", "B", "C", "D"]), 2, 2, RrMetric::Points);
+ let mut history: Vec = Vec::new();
+ let mut rounds_seen = 0;
+
+ loop {
+ let step = g.next(&history);
+ let plans = match step {
+ GeneratorStep::Run(plans) => plans,
+ GeneratorStep::Complete => break,
+ };
+ rounds_seen += 1;
+ assert!(!plans.is_empty());
+ for plan in plans {
+ // Front pilot of each heat wins it.
+ history.push(CompletedHeat::new(
+ plan.heat.0.clone(),
+ result(&[(&plan.lineup[0].0, 1, 4), (&plan.lineup[1].0, 2, 2)]),
+ ));
+ }
+ }
+
+ assert_eq!(rounds_seen, 2, "exactly two rounds were emitted");
+ // Idempotent terminal state.
+ assert_eq!(g.next(&history), GeneratorStep::Complete);
+
+ let ranking = g.ranking(&history);
+ assert_eq!(ranking.len(), 4, "the final ranking covers the whole field");
+ assert_eq!(ranking[0].position, 1);
+ for window in ranking.windows(2) {
+ assert!(window[0].position <= window[1].position);
+ }
+ }
+
+ // --- registry -------------------------------------------------------------
+
+ #[test]
+ fn registry_builds_round_robin_from_config() {
+ let mut registry = FormatRegistry::new();
+ RoundRobin::register(&mut registry);
+ assert_eq!(registry.names(), vec!["round_robin"]);
+
+ let cfg = FormatConfig::new(field(&["A", "B", "C", "D"]))
+ .with_param("rounds", "2")
+ .with_param("heat_size", "2");
+ let mut g = registry
+ .build(RoundRobin::NAME, &cfg)
+ .expect("round_robin is registered");
+ // Round 1 partitions the field into two heats of two.
+ let plans = run(g.next(&[]));
+ assert_eq!(plans.len(), 2);
+ assert_eq!(lineup_names(&plans[0]), vec!["A", "B"]);
+ assert_eq!(lineup_names(&plans[1]), vec!["C", "D"]);
+ }
+
+ #[test]
+ fn config_defaults_to_points_metric_and_default_sizes() {
+ // No params → DEFAULT_ROUNDS, DEFAULT_HEAT_SIZE, Points metric.
+ let cfg = FormatConfig::new(field(&["A", "B", "C", "D", "E"]));
+ let g = RoundRobin::from_config(&cfg);
+ // Ranking with no history lists the whole field (proves the field was taken).
+ assert_eq!(g.ranking(&[]).len(), 5);
+ }
+
+ #[test]
+ fn config_seeding_draw_orders_the_partition() {
+ use crate::format::SeedingOutcome;
+ // A recorded draw reorders the field; round 1 partitions the drawn order.
+ let cfg = FormatConfig::new(field(&["A", "B", "C", "D"]))
+ .with_seeding(SeedingOutcome::drawn(field(&["C", "A", "D", "B"])))
+ .with_param("rounds", "1")
+ .with_param("heat_size", "2");
+ let mut g = RoundRobin::from_config(&cfg);
+ let plans = run(g.next(&[]));
+ assert_eq!(lineup_names(&plans[0]), vec!["C", "A"]);
+ assert_eq!(lineup_names(&plans[1]), vec!["D", "B"]);
+ }
+
+ #[test]
+ fn config_total_laps_metric_parses() {
+ let cfg = FormatConfig::new(field(&["A", "B"]))
+ .with_param("metric", "total-laps")
+ .with_param("rounds", "1")
+ .with_param("heat_size", "2");
+ let g = RoundRobin::from_config(&cfg);
+ let completed = vec![CompletedHeat::new(
+ "rr-r1-h1",
+ result(&[("B", 1, 9), ("A", 2, 3)]),
+ )];
+ // By laps, B (9) beats A (3) even though both share the only heat.
+ let ranking = g.ranking(&completed);
+ assert_eq!(ranking[0].competitor, cref("B"));
+ }
+}
diff --git a/crates/engine/tests/round_robin_live.rs b/crates/engine/tests/round_robin_live.rs
new file mode 100644
index 0000000..d978ae6
--- /dev/null
+++ b/crates/engine/tests/round_robin_live.rs
@@ -0,0 +1,203 @@
+//! Round-robin end-to-end test (#13) — drive the [`RoundRobin`] generator loop over
+//! *real* mock-RH heats.
+//!
+//! This is the §5.1 "mock-RH e2e" for the round-robin format: rather than hand-write
+//! `HeatResult`s (the exact table tests in `round_robin::tests` do that), it closes the
+//! whole loop against a **real dockerized RotorHazard** —
+//!
+//! ```text
+//! generator.next(history) -> Run([HeatPlan, ..]) (one round's partition of heats)
+//! for each HeatPlan: run_mock_heat -> score_events(Timed) -> CompletedHeat
+//! history += completed
+//! ... repeat until next() -> Complete (after `ROUNDS` rounds) ...
+//! assert: the loop terminated and the final points ranking covers the field
+//! ```
+//!
+//! Each heat is scored under [`WinCondition::Timed`] (most laps in a window); the
+//! generator aggregates finishing-position points across every heat a pilot flew.
+//!
+//! Structural / tolerant, exactly like the other format e2es: the mock interface reads
+//! its CSV continuously, so lap *timing* is not controllable. We assert the loop
+//! **terminates** with a `Complete` after exactly `ROUNDS` rounds, that we ran one real
+//! heat per scheduled plan, and that the final ranking is non-empty, ordered, and tops
+//! out with the continuously-lapping node — never exact µs or laps. With a two-node
+//! field and `heat_size` 2 each round is a single heat of both nodes; node-0 laps
+//! throughout the live window while node-1 DNFs early, so node-0 wins every heat and
+//! tops the points table.
+//!
+//! For RH the canonical pass `at` is **ms since race start**, so the timed clock's
+//! origin is zero (`SourceTime::from_micros(0)`).
+//!
+//! Local-only class (needs Docker). DISTINCT port 5043. Run:
+//!
+//! ```sh
+//! cargo test -p gridfpv-engine --features live --test round_robin_live -- --ignored --nocapture
+//! ```
+#![cfg(feature = "live")]
+
+mod common;
+
+use common::run_mock_heat;
+
+use gridfpv_engine::format::{CompletedHeat, Generator, GeneratorStep, HeatPlan, RankEntry};
+use gridfpv_engine::round_robin::{RoundRobin, RrMetric};
+use gridfpv_engine::scoring::{WinCondition, score_events};
+use gridfpv_events::{CompetitorRef, SourceTime};
+use gridfpv_testkit::{NodeCsv, node_csv, plan_csv, scenarios};
+
+/// DISTINCT port for the round-robin e2e — never clashing with the other engine or
+/// adapter live tests (which occupy 5030–5040).
+const PORT: u16 = 5043;
+
+/// RH pass `at` is ms-since-race-start, so the timed clock starts at zero.
+fn race_start() -> SourceTime {
+ SourceTime::from_micros(0)
+}
+
+/// The win condition each heat is scored under: most laps in a generous window.
+fn condition() -> WinCondition {
+ WinCondition::Timed {
+ window_micros: 60_000_000,
+ }
+}
+
+/// The two-node scenario every heat flies: a continuous fast lapper (node-0) vs a DNF
+/// node (node-1). node-0 laps throughout the live window so it banks the most laps and
+/// wins each heat outright.
+fn scenario() -> Vec<(usize, String)> {
+ vec![
+ (
+ 0usize,
+ node_csv(&NodeCsv {
+ ticks_per_lap: 2,
+ peak_rssi: 180,
+ baseline_rssi: 70,
+ }),
+ ),
+ (1usize, plan_csv(&scenarios::dnf(2, 6))),
+ ]
+}
+
+#[test]
+#[ignore = "requires Docker (spins up dockerized RotorHazard and drives the round-robin loop over real heats)"]
+fn round_robin_loop_terminates_with_a_points_ranking() {
+ // The field the round-robin runs over: the two seat refs the harness produces.
+ let field: Vec = vec![
+ CompetitorRef("node-0".into()),
+ CompetitorRef("node-1".into()),
+ ];
+
+ // Two rounds, heat_size 2 — with two nodes each round is a single heat of both, so
+ // the loop runs two real heats total while still proving multi-round aggregation.
+ const ROUNDS: usize = 2;
+ const HEAT_SIZE: usize = 2;
+
+ let mut generator = RoundRobin::new(field.clone(), ROUNDS, HEAT_SIZE, RrMetric::Points);
+
+ // The growing history of scored heats — the generator's only input about the past.
+ let mut history: Vec = Vec::new();
+ let mut heats_run = 0usize;
+ let mut rounds_seen = 0usize;
+
+ // Drive rounds until the generator declares the format complete.
+ loop {
+ let step = generator.next(&history);
+ let plans: Vec = match step {
+ GeneratorStep::Run(plans) => plans,
+ GeneratorStep::Complete => break,
+ };
+ assert!(
+ !plans.is_empty(),
+ "a Run step must carry at least one heat plan"
+ );
+ rounds_seen += 1;
+
+ for plan in plans {
+ // Each heat covers a slice of the field; the two-node field makes it both.
+ assert!(
+ !plan.lineup.is_empty(),
+ "heat {} has an empty lineup",
+ plan.heat.0
+ );
+ assert!(
+ plan.heat.0.starts_with("rr-r"),
+ "heat id should follow the rr-r{{n}}-h{{i}} scheme, got {}",
+ plan.heat.0
+ );
+
+ let log = run_mock_heat(PORT, &plan.heat.0, &scenario());
+ let result = score_events(&log, condition(), race_start());
+
+ assert!(
+ !result.places.is_empty(),
+ "heat {} produced no scored competitors",
+ plan.heat.0
+ );
+
+ eprintln!(
+ "round-robin e2e: heat {} scored {} competitor(s)",
+ plan.heat.0,
+ result.places.len()
+ );
+
+ history.push(CompletedHeat {
+ heat: plan.heat.clone(),
+ result,
+ });
+ heats_run += 1;
+ }
+ }
+
+ // The loop terminated via Complete after exactly the configured rounds.
+ assert_eq!(
+ rounds_seen, ROUNDS,
+ "the loop should run exactly ROUNDS rounds"
+ );
+ assert_eq!(
+ heats_run, ROUNDS,
+ "two nodes at heat_size 2 → one heat per round → ROUNDS heats total"
+ );
+
+ // Calling next again still completes (idempotent terminal state).
+ assert_eq!(
+ generator.next(&history),
+ GeneratorStep::Complete,
+ "the generator stays Complete once all rounds are flown"
+ );
+
+ // The final points ranking covers the field, is in non-decreasing position order,
+ // and starts at position 1 — structural assertions only.
+ let ranking: Vec = generator.ranking(&history);
+ assert_eq!(
+ ranking.len(),
+ field.len(),
+ "the final ranking should cover the whole field"
+ );
+ assert_eq!(
+ ranking[0].position, 1,
+ "the ranking must start at position 1"
+ );
+ for window in ranking.windows(2) {
+ assert!(
+ window[0].position <= window[1].position,
+ "ranking must be in non-decreasing position order"
+ );
+ }
+
+ // node-0 laps continuously and wins every heat; node-1 DNFs, so node-0 banks the
+ // most points and tops the table. (Structural: which node wins is fixed by the
+ // scenario, not by timing.)
+ assert_eq!(
+ ranking[0].competitor,
+ CompetitorRef("node-0".into()),
+ "the continuously-lapping node should top the points ranking"
+ );
+
+ println!(
+ "round-robin e2e: ran {heats_run} heats over {ROUNDS} rounds; final ranking = {:?}",
+ ranking
+ .iter()
+ .map(|e| (e.competitor.0.as_str(), e.position))
+ .collect::>()
+ );
+}
From 7eefe94569d729b05ef00f036dd5eb7710ab0780 Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 16:59:18 +0000
Subject: [PATCH 036/362] Add double-elimination format generator
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implement `DoubleElim`, a head-to-head double-elimination bracket
generator over a seeded field, as the natural extension of `SingleElim`.
A loss in the winners bracket (WB) drops a competitor into the losers
bracket (LB); a second loss eliminates. The LB alternates minor rounds
(LB survivors pair off) and major rounds (LB survivors meet the newest
WB droppers). The undefeated WB champion meets the once-defeated LB
champion in the grand final. The replay-based `next`/`ranking` core walks
both brackets in lock-step from the completed history — pure, no clock or
RNG; a recorded `SeedingOutcome` and `bracket_pairs` drive seeding.
Grand-final bracket reset is implemented and config-gated (`bracket_reset`,
default on): if the LB champion wins grand-final heat 1 both have one loss,
so a deterministic decider (`de-gf-reset`) is emitted; an undefeated WB
champion win needs none. Byes (odd fields) advance the trailing competitor
deterministically. Registered under "double_elim".
Tests: table tests for 3/4/8-seed brackets to a champion, loser-drops-to-LB,
second-loss-eliminates, lower-seed comeback through losers, byes, no-reset
config, determinism, and registry. Mock-RH e2e (`double_elim_live`, live
feature, ignored, port 5042) drives a 4-seed bracket over real dockerized
RotorHazard heats to a single champion.
`cargo xtask ci` green; `double_elim_live` passes under Docker.
Part of #13 (double-elim generator).
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
crates/engine/src/double_elim.rs | 944 +++++++++++++++++++++++-
crates/engine/tests/double_elim_live.rs | 211 ++++++
2 files changed, 1154 insertions(+), 1 deletion(-)
create mode 100644 crates/engine/tests/double_elim_live.rs
diff --git a/crates/engine/src/double_elim.rs b/crates/engine/src/double_elim.rs
index 6eabbb8..16e1b72 100644
--- a/crates/engine/src/double_elim.rs
+++ b/crates/engine/src/double_elim.rs
@@ -1 +1,943 @@
-//! double_elim format generator — implements format::Generator (filled by the agent).
+//! Double-elimination bracket generator (#13) — a [`Generator`] that seeds a field
+//! into a **double**-elimination bracket: a loss drops a competitor from the winners
+//! bracket into the losers bracket, and a second loss eliminates them. The last two
+//! standing (the undefeated winners champion and the once-defeated losers champion)
+//! meet in the grand final.
+//!
+//! # The format (race-engine.html §3, §5)
+//!
+//! Double elimination is the natural extension of [`crate::single_elim`]: the same
+//! fixed-but-state-driven shape (RE §3) — the whole bracket is determined by the
+//! seeded field, yet every round is derived from the results so far. `next` reads the
+//! completed heats, takes each head-to-head heat's **winner** (the top of the
+//! result), and lays out the next round across **both** brackets. It reads no clock or
+//! RNG: any seeding draw is resolved once and injected as a [`SeedingOutcome`] at
+//! construction (RE §6), so the bracket replays identically.
+//!
+//! This generator runs **head-to-head (2-pilot) heats** for correctness: every match
+//! is a clean win/loss, which is what makes "drop the loser, eliminate on a second
+//! loss" unambiguous. Larger-heat variants (advance the top half, drop the bottom
+//! half into losers) are a future extension and are intentionally out of scope here.
+//!
+//! # Bracket bookkeeping
+//!
+//! 1. **Seed the field.** The config carries the field in seed order (best first); a
+//! recorded [`SeedingOutcome`] is applied first (identity if none). The first
+//! winners round pairs strong-vs-weak via [`bracket_pairs`] (1 v N, 2 v N-1, …),
+//! the standard seeding that keeps top seeds apart.
+//! 2. **Winners bracket (WB).** A plain single-elimination knockout. The **winner** of
+//! each WB heat advances within the WB; the **loser** drops into the losers bracket
+//! (LB). The WB runs until one undefeated competitor remains — the WB champion.
+//! 3. **Losers bracket (LB).** Fed by the WB's droppers. It alternates two kinds of
+//! rounds, the standard double-elim layout:
+//! - a **minor** round pairs the current LB survivors against each other, then
+//! - a **major** round pairs those survivors against the newest batch of WB
+//! droppers.
+//!
+//! The loser of *any* LB heat is eliminated (their second loss). The LB runs until
+//! one once-defeated competitor remains — the LB champion.
+//! 4. **Grand final.** WB champion (0 losses) vs LB champion (1 loss).
+//!
+//! # Grand-final bracket reset (documented choice)
+//!
+//! A purist double-elimination grand final is *true*: because the LB champion has one
+//! loss and the WB champion has none, if the LB champion wins the first grand-final
+//! heat both competitors then have one loss, so a **second** decider ("the reset") is
+//! played. If the WB champion wins the first heat, the LB champion is eliminated on
+//! their second loss and no reset is needed.
+//!
+//! This generator implements the **bracket reset**, gated by the `bracket_reset`
+//! config param (default **on**, `"1"`; set `"0"` for a single-heat "winner-takes-it"
+//! grand final). The choice is fully deterministic: the reset heat is emitted **iff**
+//! the LB champion won the first grand-final heat *and* reset is enabled — a pure
+//! function of the completed history, never a clock or a flag mutated mid-run.
+//!
+//! # Byes (odd / short fields)
+//!
+//! When a round's competitor list is odd, the **trailing** competitor (the middle seed
+//! in the first WB round, by [`bracket_pairs`]; the last survivor otherwise) takes a
+//! **bye** and advances for free, deterministically. The same rule applies in the LB,
+//! so an odd field still converges.
+//!
+//! # Ranking
+//!
+//! [`Generator::ranking`] is the bracket placement: the **champion** first, the
+//! **grand-final loser** second, then everyone else ordered by **how far they got** —
+//! a competitor eliminated in a later round (WB or LB) outranks one knocked out
+//! earlier. Within an elimination round, finishers keep the in-heat order, with the
+//! competitor ref as the final deterministic tie-break. Provisional before the bracket
+//! completes, final after.
+#![forbid(unsafe_code)]
+
+use std::collections::BTreeMap;
+
+use gridfpv_events::CompetitorRef;
+
+use crate::format::{
+ CompletedHeat, FormatConfig, FormatRegistry, Generator, GeneratorStep, HeatPlan, RankEntry,
+ bracket_pairs, rank_by, result_ranking,
+};
+use crate::scoring::HeatResult;
+
+/// A double-elimination bracket over a seeded field (head-to-head heats).
+///
+/// Constructed with the seeded field (draw order already applied) and whether the
+/// grand final allows a [bracket reset](self#grand-final-bracket-reset-documented-choice);
+/// `next` drives the rounds and `ranking` reports the bracket placement. See the module
+/// docs for the seeding / bye / WB-LB / grand-final rules.
+pub struct DoubleElim {
+ /// The field in seed/draw order (the recorded [`SeedingOutcome`] already applied).
+ field: Vec,
+ /// Whether a losers-champion win in grand-final heat 1 forces a decider (reset).
+ bracket_reset: bool,
+}
+
+impl DoubleElim {
+ /// The format name this registers under.
+ pub const NAME: &'static str = "double_elim";
+
+ /// Build over a `field` in seed order, with the grand-final bracket reset enabled
+ /// or disabled.
+ pub fn new(field: Vec, bracket_reset: bool) -> Self {
+ Self {
+ field,
+ bracket_reset,
+ }
+ }
+
+ /// The registry constructor: applies the recorded `seeding` draw to `field` and
+ /// reads the optional `bracket_reset` param (default on — any value other than
+ /// `"0"`/`"false"` keeps it enabled).
+ pub fn from_config(config: &FormatConfig) -> Box {
+ let field = config.seeding.apply(&config.field);
+ let bracket_reset = !matches!(
+ config.params.get("bracket_reset").map(String::as_str),
+ Some("0") | Some("false") | Some("off") | Some("no")
+ );
+ Box::new(Self::new(field, bracket_reset))
+ }
+
+ /// Register this format under [`NAME`](Self::NAME).
+ pub fn register(registry: &mut FormatRegistry) {
+ registry.register(Self::NAME, Self::from_config);
+ }
+
+ // --- Heat-id scheme -----------------------------------------------------
+ //
+ // Winners: `de-w{round}-h{index}` (round, heat index both 0-based-ish: round
+ // 1-based, heat 0-based, mirroring single_elim's `se-r{round}-h{index}`).
+ // Losers: `de-l{round}-h{index}`.
+ // Grand final: `de-gf` (first heat) and `de-gf-reset` (the decider).
+
+ fn winners_id(round: usize, index: usize) -> String {
+ format!("de-w{round}-h{index}")
+ }
+
+ fn losers_id(round: usize, index: usize) -> String {
+ format!("de-l{round}-h{index}")
+ }
+
+ const GRAND_FINAL: &'static str = "de-gf";
+ const GRAND_FINAL_RESET: &'static str = "de-gf-reset";
+
+ /// Pair `order` into head-to-head heats with the given id builder, returning the
+ /// heat plans **and** the bye (a single trailing competitor advances for free).
+ ///
+ /// Deterministic: chunks are taken left-to-right; a trailing single is the bye.
+ fn pair_round(
+ round: usize,
+ order: &[CompetitorRef],
+ id: impl Fn(usize, usize) -> String,
+ ) -> (Vec, Option) {
+ let mut heats = Vec::new();
+ let mut bye = None;
+ let mut index = 0usize;
+ let mut chunks = order.chunks(2);
+ for chunk in &mut chunks {
+ if chunk.len() == 2 {
+ heats.push(HeatPlan::new(id(round, index), chunk.to_vec()));
+ index += 1;
+ } else {
+ bye = Some(chunk[0].clone());
+ }
+ }
+ (heats, bye)
+ }
+
+ /// Replay the whole bracket from the completed history. This is the pure core both
+ /// [`next`](Generator::next) and [`ranking`](Generator::ranking) build on: it walks
+ /// the winners bracket and losers bracket round by round in lock-step, matching
+ /// each round's emitted heats against `completed` by heat id, advancing winners and
+ /// dropping losers, and only progressing past a round once every one of its heats
+ /// has come back.
+ fn replay(&self, completed: &[CompletedHeat]) -> Replay {
+ let by_id: BTreeMap<&str, &HeatResult> = completed
+ .iter()
+ .map(|c| (c.heat.0.as_str(), &c.result))
+ .collect();
+
+ // The winner / loser of a completed heat, looked up by id. `None` if the heat
+ // has not come back yet.
+ let outcome = |heat_id: &str| -> Option<(CompetitorRef, CompetitorRef)> {
+ let result = by_id.get(heat_id)?;
+ let ranking = result_ranking(result);
+ // Head-to-head: top is the winner, next is the loser.
+ let winner = ranking.first()?.competitor.clone();
+ let loser = ranking.get(1)?.competitor.clone();
+ Some((winner, loser))
+ };
+
+ let mut pending: Vec = Vec::new();
+ // Per round (earliest first) the competitors that round eliminated, in heat
+ // order — the second-loss losers, used to band the ranking.
+ let mut eliminated_by_round: Vec> = Vec::new();
+
+ // --- Winners bracket: advance like a single-elim, collecting droppers. ---
+ //
+ // `wb_drops[r]` is the batch of competitors who lost in winners round `r`
+ // (1-based), in heat order — the feed into the losers bracket.
+ let mut wb_round = 1usize;
+ let mut wb_order = bracket_pairs(&self.field);
+ let mut wb_drops: Vec> = vec![Vec::new()]; // index 0 unused
+ let wb_champion: Option = loop {
+ if wb_order.len() <= 1 {
+ break wb_order.first().cloned();
+ }
+ let (heats, bye) = Self::pair_round(wb_round, &wb_order, Self::winners_id);
+ let mut next_order = Vec::new();
+ let mut drops = Vec::new();
+ let mut complete = true;
+ for heat in &heats {
+ match outcome(&heat.heat.0) {
+ Some((winner, loser)) => {
+ next_order.push(winner);
+ drops.push(loser);
+ }
+ None => {
+ complete = false;
+ pending.push(heat.clone());
+ }
+ }
+ }
+ if !complete {
+ // This WB round is still running; stop advancing the WB. We still let
+ // the LB run on the droppers already produced by earlier WB rounds.
+ wb_drops.push(Vec::new());
+ break None;
+ }
+ if let Some(b) = bye {
+ next_order.push(b);
+ }
+ wb_drops.push(drops);
+ wb_order = next_order;
+ wb_round += 1;
+ };
+ let wb_settled = wb_champion.is_some();
+
+ // --- Losers bracket: alternate minor (LB-vs-LB) and major (LB-vs-WB-drop). ---
+ //
+ // LB round numbering is its own sequence (1-based). The standard layout:
+ // L1 (minor): pair the round-1 WB droppers against each other.
+ // L2 (major): the L1 survivors meet the round-2 WB droppers.
+ // L3 (minor): pair the L2 survivors against each other.
+ // L4 (major): the L3 survivors meet the round-3 WB droppers.
+ // … until one LB survivor remains (the LB champion).
+ //
+ // We only lay out an LB round once its inputs exist: a major round needs that
+ // WB round's droppers to have been produced (which requires the WB to have run
+ // that far). If the inputs are not ready, the LB waits — exactly mirroring how
+ // a real schedule cannot run a losers heat before its feeders finish.
+ let mut lb_round = 1usize;
+ let mut lb_survivors: Vec = Vec::new();
+ // Which WB drop-batch the next *major* round will consume (round 2 first).
+ let mut next_drop_round = 2usize;
+ // The LB heat-id round counter is independent of the minor/major distinction;
+ // it just increments per LB round we lay out.
+ let lb_champion: Option = loop {
+ // Inject the first batch of droppers as the LB seed before round 1.
+ if lb_round == 1 {
+ lb_survivors = wb_drops.get(1).cloned().unwrap_or_default();
+ }
+
+ let is_major = lb_round % 2 == 0;
+ if is_major {
+ // Major round: bring in the next WB drop batch. It must be available —
+ // i.e. that WB round must have fully completed. If not, the LB stalls
+ // here until the WB produces it.
+ let drop_ready = wb_settled || next_drop_round < wb_drops.len();
+ let incoming = wb_drops.get(next_drop_round).cloned();
+ match incoming {
+ Some(drops) if drop_ready => {
+ // Interleave survivors with incoming droppers for the pairing.
+ // Standard practice cross-seeds them; we keep it deterministic
+ // and simple: survivors first (in their order), then droppers.
+ lb_survivors.extend(drops);
+ next_drop_round += 1;
+ }
+ _ => {
+ // The feeding WB round has not finished; the LB cannot run this
+ // major round yet. Stop here (no LB champion settled).
+ break None;
+ }
+ }
+ }
+
+ if lb_survivors.len() <= 1 {
+ // One (or zero) LB competitor left. If the WB is settled this is the LB
+ // champion; otherwise the LB is just waiting on more droppers.
+ if wb_settled && next_drop_round >= wb_drops.len() {
+ break lb_survivors.first().cloned();
+ }
+ // More droppers are still coming (a future major round). Advance the
+ // round counter so the next major round pulls them in.
+ if !is_major {
+ lb_round += 1;
+ continue;
+ }
+ break None;
+ }
+
+ let (heats, bye) = Self::pair_round(lb_round, &lb_survivors, Self::losers_id);
+ let mut next_survivors = Vec::new();
+ let mut eliminated = Vec::new();
+ let mut complete = true;
+ for heat in &heats {
+ match outcome(&heat.heat.0) {
+ Some((winner, loser)) => {
+ next_survivors.push(winner);
+ eliminated.push(loser);
+ }
+ None => {
+ complete = false;
+ pending.push(heat.clone());
+ }
+ }
+ }
+ if !complete {
+ break None;
+ }
+ if let Some(b) = bye {
+ next_survivors.push(b);
+ }
+ if !eliminated.is_empty() {
+ eliminated_by_round.push(eliminated);
+ }
+ lb_survivors = next_survivors;
+ lb_round += 1;
+ };
+
+ // --- Grand final ----------------------------------------------------
+ //
+ // Only meaningful once BOTH champions are known. The WB champion has 0 losses,
+ // the LB champion 1. First heat `de-gf`; an optional reset `de-gf-reset`.
+ let mut champion: Option = None;
+ let mut runner_up: Option = None;
+ if let (Some(wb_champ), Some(lb_champ)) = (wb_champion.clone(), lb_champion.clone()) {
+ match outcome(Self::GRAND_FINAL) {
+ None => {
+ // Grand final not yet run: emit it. WB champ seeded first.
+ pending.push(HeatPlan::new(
+ Self::GRAND_FINAL,
+ vec![wb_champ.clone(), lb_champ.clone()],
+ ));
+ }
+ Some((gf_winner, gf_loser)) => {
+ if gf_winner == wb_champ {
+ // WB champ won → LB champ takes their 2nd loss. Done, no reset.
+ champion = Some(wb_champ.clone());
+ runner_up = Some(gf_loser);
+ } else {
+ // LB champ won heat 1 → both now have one loss.
+ if self.bracket_reset {
+ // Reset: a decider settles it.
+ match outcome(Self::GRAND_FINAL_RESET) {
+ None => {
+ pending.push(HeatPlan::new(
+ Self::GRAND_FINAL_RESET,
+ vec![wb_champ.clone(), lb_champ.clone()],
+ ));
+ }
+ Some((reset_winner, reset_loser)) => {
+ champion = Some(reset_winner);
+ runner_up = Some(reset_loser);
+ }
+ }
+ } else {
+ // No reset: the single grand final stands.
+ champion = Some(gf_winner);
+ runner_up = Some(gf_loser);
+ }
+ }
+ }
+ }
+ }
+
+ Replay {
+ pending,
+ champion,
+ runner_up,
+ wb_champion,
+ lb_champion,
+ lb_survivors,
+ eliminated_by_round,
+ }
+ }
+}
+
+/// The outcome of replaying the completed history (see [`DoubleElim::replay`]).
+struct Replay {
+ /// Heats awaiting a result, across both brackets and the grand final. Empty once
+ /// the bracket is complete.
+ pending: Vec,
+ /// The overall champion, once the grand final has resolved.
+ champion: Option,
+ /// The grand-final loser (overall runner-up), once it has resolved.
+ runner_up: Option,
+ /// The winners-bracket champion, once the WB has settled (0 losses).
+ wb_champion: Option,
+ /// The losers-bracket champion, once the LB has settled (1 loss).
+ lb_champion: Option,
+ /// The competitors still alive in the losers bracket mid-run (for provisional
+ /// ranking); collapses to the LB champion once it settles.
+ lb_survivors: Vec,
+ /// Per LB round (earliest first), the competitors that round eliminated (their
+ /// second loss), in heat order.
+ eliminated_by_round: Vec>,
+}
+
+impl Generator for DoubleElim {
+ fn next(&mut self, completed: &[CompletedHeat]) -> GeneratorStep {
+ let replay = self.replay(completed);
+ if replay.pending.is_empty() && replay.champion.is_some() {
+ GeneratorStep::Complete
+ } else if replay.pending.is_empty() {
+ // Defensive: no pending heats but no champion yet. With a well-formed
+ // history this does not happen (a stalled bracket always has a pending
+ // heat or a champion); treat it as complete so the loop terminates.
+ GeneratorStep::Complete
+ } else {
+ GeneratorStep::Run(replay.pending)
+ }
+ }
+
+ fn ranking(&self, completed: &[CompletedHeat]) -> Vec {
+ let replay = self.replay(completed);
+
+ // Bands, best first:
+ // 0: champion (once known) — alone at the top.
+ // 1: grand-final loser (runner-up).
+ // then: still-alive competitors not yet placed (WB/LB champions mid-run,
+ // LB survivors), in a "furthest along" band,
+ // then: each LB elimination round, latest first (further = higher),
+ // finally: everyone who never appeared (e.g. a never-run field) by seed.
+ let mut rows: Vec<(CompetitorRef, (u32, u32))> = Vec::new();
+ let mut placed: Vec = Vec::new();
+ let mut band = 0u32;
+
+ let push = |rows: &mut Vec<(CompetitorRef, (u32, u32))>,
+ placed: &mut Vec,
+ competitor: &CompetitorRef,
+ band: u32,
+ within: u32| {
+ if !placed.contains(competitor) {
+ placed.push(competitor.clone());
+ rows.push((competitor.clone(), (band, within)));
+ }
+ };
+
+ if let Some(champ) = &replay.champion {
+ push(&mut rows, &mut placed, champ, band, 0);
+ }
+ band += 1;
+ if let Some(ru) = &replay.runner_up {
+ push(&mut rows, &mut placed, ru, band, 0);
+ }
+ band += 1;
+
+ // Still-alive-but-unplaced competitors (mid-run): the WB champion waiting on
+ // the LB, the LB champion waiting on the grand final, the LB survivors. They
+ // outrank everyone already eliminated.
+ if let Some(wb) = &replay.wb_champion {
+ push(&mut rows, &mut placed, wb, band, 0);
+ }
+ if let Some(lb) = &replay.lb_champion {
+ push(&mut rows, &mut placed, lb, band, 1);
+ }
+ for (i, survivor) in replay.lb_survivors.iter().enumerate() {
+ push(&mut rows, &mut placed, survivor, band, 2 + i as u32);
+ }
+ band += 1;
+
+ // Eliminated in the losers bracket, latest round first (advanced furthest).
+ for eliminated in replay.eliminated_by_round.iter().rev() {
+ for (i, competitor) in eliminated.iter().enumerate() {
+ push(&mut rows, &mut placed, competitor, band, i as u32);
+ }
+ band += 1;
+ }
+
+ // Any field member never seen anywhere (e.g. before any heat / tiny field):
+ // keep them in seed order behind everyone, so the ranking is always total.
+ for (i, competitor) in self.field.iter().enumerate() {
+ push(&mut rows, &mut placed, competitor, band, i as u32);
+ }
+
+ rank_by(rows)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::format::SeedingOutcome;
+ use crate::scoring::{Metric, Placement};
+ use gridfpv_events::AdapterId;
+ use gridfpv_projection::CompetitorKey;
+
+ const ADAPTER: &str = "demo";
+
+ fn cref(name: &str) -> CompetitorRef {
+ CompetitorRef(name.into())
+ }
+
+ fn field(names: &[&str]) -> Vec {
+ names.iter().map(|n| cref(n)).collect()
+ }
+
+ /// Build a `HeatResult` from `(name, position, laps)` rows.
+ fn result(rows: &[(&str, u32, u32)]) -> HeatResult {
+ HeatResult {
+ places: rows
+ .iter()
+ .map(|(name, position, laps)| Placement {
+ competitor: CompetitorKey {
+ adapter: AdapterId(ADAPTER.into()),
+ competitor: cref(name),
+ },
+ position: *position,
+ laps: *laps,
+ metric: Metric::LastLapAt(None),
+ })
+ .collect(),
+ }
+ }
+
+ /// A head-to-head result where `winner` beats `loser`.
+ fn h2h(winner: &str, loser: &str) -> HeatResult {
+ result(&[(winner, 1, 5), (loser, 2, 3)])
+ }
+
+ fn names(entries: &[RankEntry]) -> Vec {
+ entries.iter().map(|e| e.competitor.0.clone()).collect()
+ }
+
+ /// The (heat-id, lineup) pairs of a `Run` step (panics if `Complete`).
+ fn lineups(step: &GeneratorStep) -> Vec<(String, Vec)> {
+ match step {
+ GeneratorStep::Run(heats) => heats
+ .iter()
+ .map(|h| {
+ (
+ h.heat.0.clone(),
+ h.lineup.iter().map(|c| c.0.clone()).collect(),
+ )
+ })
+ .collect(),
+ GeneratorStep::Complete => panic!("expected Run, got Complete"),
+ }
+ }
+
+ /// Just the heat ids of a `Run` step.
+ fn heat_ids(step: &GeneratorStep) -> Vec {
+ lineups(step).into_iter().map(|(id, _)| id).collect()
+ }
+
+ /// Drive a generator to completion, resolving every emitted heat with `pick`
+ /// (given a heat id + its lineup, return the winner). Returns the full completed
+ /// history. Panics if it does not converge within a generous round budget.
+ fn drive(
+ g: &mut DoubleElim,
+ pick: impl Fn(&str, &[CompetitorRef]) -> CompetitorRef,
+ ) -> Vec {
+ let mut completed: Vec = Vec::new();
+ let mut rounds = 0;
+ while let GeneratorStep::Run(heats) = g.next(&completed) {
+ rounds += 1;
+ assert!(rounds < 32, "bracket should converge well within 32 rounds");
+ assert!(!heats.is_empty(), "a Run step must carry at least one heat");
+ for plan in &heats {
+ let winner = pick(&plan.heat.0, &plan.lineup);
+ let loser = plan
+ .lineup
+ .iter()
+ .find(|c| **c != winner)
+ .cloned()
+ .expect("head-to-head heat has a loser");
+ completed.push(CompletedHeat::new(
+ plan.heat.0.clone(),
+ h2h(&winner.0, &loser.0),
+ ));
+ }
+ }
+ completed
+ }
+
+ /// A `pick` where the lower numeric seed (smaller ref) always wins — i.e. a
+ /// perfectly chalk bracket.
+ fn chalk(_id: &str, lineup: &[CompetitorRef]) -> CompetitorRef {
+ lineup
+ .iter()
+ .min_by_key(|c| c.0.parse::().unwrap_or(u32::MAX))
+ .cloned()
+ .expect("non-empty lineup")
+ }
+
+ // --- Round-1 layout -----------------------------------------------------
+
+ #[test]
+ fn winners_round_one_pairs_strong_vs_weak_head_to_head() {
+ let mut g = DoubleElim::new(field(&["1", "2", "3", "4"]), true);
+ // bracket order 1,4,2,3 → WB heats (1 v 4), (2 v 3). No LB/GF yet.
+ assert_eq!(
+ lineups(&g.next(&[])),
+ vec![
+ ("de-w1-h0".to_string(), vec!["1".into(), "4".into()]),
+ ("de-w1-h1".to_string(), vec!["2".into(), "3".into()]),
+ ]
+ );
+ }
+
+ // --- Loser drops to losers bracket --------------------------------------
+
+ #[test]
+ fn winners_loser_drops_into_the_losers_bracket() {
+ let mut g = DoubleElim::new(field(&["1", "2", "3", "4"]), true);
+ let _ = g.next(&[]);
+ // WB round 1: 1 beats 4, 2 beats 3. Droppers: 4, 3.
+ let completed = vec![
+ CompletedHeat::new("de-w1-h0", h2h("1", "4")),
+ CompletedHeat::new("de-w1-h1", h2h("2", "3")),
+ ];
+ // Next emits the WB final (1 v 2) AND the LB round-1 minor (4 v 3).
+ let ids = heat_ids(&g.next(&completed));
+ assert!(ids.contains(&"de-w2-h0".to_string()), "WB final emitted");
+ assert!(
+ ids.contains(&"de-l1-h0".to_string()),
+ "LB round-1 minor emitted from the WB droppers"
+ );
+ // The LB heat pairs the two WB-round-1 losers.
+ let lb = lineups(&g.next(&completed))
+ .into_iter()
+ .find(|(id, _)| id == "de-l1-h0")
+ .unwrap();
+ assert_eq!(lb.1, vec!["4".to_string(), "3".to_string()]);
+ }
+
+ // --- A second loss eliminates -------------------------------------------
+
+ #[test]
+ fn a_second_loss_eliminates_in_the_losers_bracket() {
+ let mut g = DoubleElim::new(field(&["1", "2", "3", "4"]), true);
+ // WB r1: 1>4, 2>3. WB final & LB minor open.
+ let mut completed = vec![
+ CompletedHeat::new("de-w1-h0", h2h("1", "4")),
+ CompletedHeat::new("de-w1-h1", h2h("2", "3")),
+ ];
+ let _ = g.next(&completed);
+ // WB final: 1 beats 2 → 2 drops to LB. LB minor: 4 beats 3 → 3 is eliminated
+ // (its 2nd loss).
+ completed.push(CompletedHeat::new("de-w2-h0", h2h("1", "2")));
+ completed.push(CompletedHeat::new("de-l1-h0", h2h("4", "3")));
+ // Now the LB major pairs the LB survivor (4) with the WB-final dropper (2).
+ let ids = heat_ids(&g.next(&completed));
+ assert!(
+ ids.contains(&"de-l2-h0".to_string()),
+ "LB major round emitted, got {ids:?}"
+ );
+ let lb_major = lineups(&g.next(&completed))
+ .into_iter()
+ .find(|(id, _)| id == "de-l2-h0")
+ .unwrap();
+ // Survivor 4 vs WB-final loser 2.
+ assert_eq!(lb_major.1, vec!["4".to_string(), "2".to_string()]);
+ }
+
+ // --- A once-beaten lower seed can still win via the losers bracket -------
+
+ #[test]
+ fn lower_seed_loses_once_then_wins_through_losers_to_champion() {
+ let mut g = DoubleElim::new(field(&["1", "2", "3", "4"]), true);
+ // Seed 2 will lose in the WB but storm back through the LB and win it all.
+ // WB r1: 1>4, 2>3.
+ let mut completed = vec![
+ CompletedHeat::new("de-w1-h0", h2h("1", "4")),
+ CompletedHeat::new("de-w1-h1", h2h("2", "3")),
+ ];
+ let _ = g.next(&completed);
+ // WB final: 1 beats 2 (2's first loss → drops to LB). LB minor: 4 beats 3.
+ completed.push(CompletedHeat::new("de-w2-h0", h2h("1", "2")));
+ completed.push(CompletedHeat::new("de-l1-h0", h2h("4", "3")));
+ let _ = g.next(&completed);
+ // LB major: 2 beats 4 (4 eliminated) → 2 is the LB champion.
+ completed.push(CompletedHeat::new("de-l2-h0", h2h("2", "4")));
+ // Grand final: WB champ 1 vs LB champ 2.
+ let gf = lineups(&g.next(&completed));
+ assert_eq!(
+ gf,
+ vec![("de-gf".to_string(), vec!["1".into(), "2".into()])]
+ );
+ // 2 wins the grand final heat 1 → both have one loss → reset decider.
+ completed.push(CompletedHeat::new("de-gf", h2h("2", "1")));
+ let reset = lineups(&g.next(&completed));
+ assert_eq!(
+ reset,
+ vec![("de-gf-reset".to_string(), vec!["1".into(), "2".into()])]
+ );
+ // 2 wins the reset → 2 is champion despite an early WB loss.
+ completed.push(CompletedHeat::new("de-gf-reset", h2h("2", "1")));
+ assert_eq!(g.next(&completed), GeneratorStep::Complete);
+
+ let ranking = g.ranking(&completed);
+ assert_eq!(ranking[0].competitor, cref("2"));
+ assert_eq!(ranking[0].position, 1);
+ assert_eq!(ranking[1].competitor, cref("1"));
+ assert_eq!(ranking[1].position, 2);
+ }
+
+ // --- Grand final: WB champ wins heat 1, no reset ------------------------
+
+ #[test]
+ fn grand_final_wb_champ_win_needs_no_reset() {
+ let mut g = DoubleElim::new(field(&["1", "2", "3", "4"]), true);
+ let completed = drive(&mut g, chalk);
+ // Chalk: seed 1 wins every heat. No reset heat should ever be emitted.
+ assert!(
+ !completed.iter().any(|c| c.heat.0 == "de-gf-reset"),
+ "WB champ winning GF heat 1 needs no reset"
+ );
+ assert!(
+ completed.iter().any(|c| c.heat.0 == "de-gf"),
+ "a grand final was played"
+ );
+ let ranking = g.ranking(&completed);
+ assert_eq!(ranking[0].competitor, cref("1"));
+ assert_eq!(ranking[0].position, 1);
+ }
+
+ // --- bracket_reset = false: single grand final --------------------------
+
+ #[test]
+ fn no_reset_config_plays_a_single_grand_final() {
+ let mut g = DoubleElim::new(field(&["1", "2", "3", "4"]), false);
+ // WB r1.
+ let mut completed = vec![
+ CompletedHeat::new("de-w1-h0", h2h("1", "4")),
+ CompletedHeat::new("de-w1-h1", h2h("2", "3")),
+ ];
+ let _ = g.next(&completed);
+ completed.push(CompletedHeat::new("de-w2-h0", h2h("1", "2")));
+ completed.push(CompletedHeat::new("de-l1-h0", h2h("4", "3")));
+ let _ = g.next(&completed);
+ completed.push(CompletedHeat::new("de-l2-h0", h2h("2", "4")));
+ let _ = g.next(&completed);
+ // LB champ 2 wins the GF heat — with reset OFF that is the title, no decider.
+ completed.push(CompletedHeat::new("de-gf", h2h("2", "1")));
+ assert_eq!(g.next(&completed), GeneratorStep::Complete);
+ let ranking = g.ranking(&completed);
+ assert_eq!(ranking[0].competitor, cref("2"));
+ assert_eq!(ranking[1].competitor, cref("1"));
+ }
+
+ // --- Byes (odd field) ---------------------------------------------------
+
+ #[test]
+ fn odd_field_gives_the_middle_seed_a_winners_bye() {
+ // 3 seeds: bracket order 1,3,2 → WB heat (1 v 3), bye for 2.
+ let mut g = DoubleElim::new(field(&["1", "2", "3"]), true);
+ assert_eq!(
+ lineups(&g.next(&[])),
+ vec![("de-w1-h0".to_string(), vec!["1".into(), "3".into()])]
+ );
+ // 1 beats 3. WB final: 1 v 2 (2 had the bye). 3 dropped alone into the LB —
+ // with a single LB competitor there is no LB heat yet (it waits for more).
+ let completed = vec![CompletedHeat::new("de-w1-h0", h2h("1", "3"))];
+ let ids = heat_ids(&g.next(&completed));
+ assert!(ids.contains(&"de-w2-h0".to_string()));
+ let wb_final = lineups(&g.next(&completed))
+ .into_iter()
+ .find(|(id, _)| id == "de-w2-h0")
+ .unwrap();
+ assert_eq!(wb_final.1, vec!["1".to_string(), "2".to_string()]);
+ }
+
+ #[test]
+ fn three_seed_bracket_runs_to_a_champion() {
+ let mut g = DoubleElim::new(field(&["1", "2", "3"]), true);
+ let completed = drive(&mut g, chalk);
+ let ranking = g.ranking(&completed);
+ assert_eq!(ranking.len(), 3, "every seed appears in the ranking");
+ assert_eq!(ranking[0].competitor, cref("1"));
+ assert_eq!(ranking[0].position, 1);
+ assert_eq!(
+ ranking.iter().filter(|e| e.position == 1).count(),
+ 1,
+ "exactly one champion"
+ );
+ }
+
+ // --- Full small brackets to a champion ----------------------------------
+
+ #[test]
+ fn full_four_seed_chalk_bracket_runs_to_top_seed() {
+ let mut g = DoubleElim::new(field(&["1", "2", "3", "4"]), true);
+ let completed = drive(&mut g, chalk);
+ let ranking = g.ranking(&completed);
+ assert_eq!(ranking.len(), 4);
+ assert_eq!(ranking[0].competitor, cref("1"));
+ assert_eq!(ranking[0].position, 1);
+ assert_eq!(
+ ranking.iter().filter(|e| e.position == 1).count(),
+ 1,
+ "exactly one champion"
+ );
+ // The runner-up is the grand-final loser; everyone appears exactly once.
+ let mut seen: Vec = names(&ranking);
+ seen.sort();
+ assert_eq!(seen, vec!["1", "2", "3", "4"]);
+ }
+
+ #[test]
+ fn full_eight_seed_chalk_bracket_runs_to_top_seed() {
+ let mut g = DoubleElim::new(field(&["1", "2", "3", "4", "5", "6", "7", "8"]), true);
+ let completed = drive(&mut g, chalk);
+ let ranking = g.ranking(&completed);
+ assert_eq!(ranking.len(), 8, "every seed appears once");
+ assert_eq!(ranking[0].competitor, cref("1"));
+ assert_eq!(ranking[0].position, 1);
+ assert_eq!(
+ ranking.iter().filter(|e| e.position == 1).count(),
+ 1,
+ "exactly one champion"
+ );
+ let mut seen: Vec = names(&ranking);
+ seen.sort();
+ assert_eq!(seen, vec!["1", "2", "3", "4", "5", "6", "7", "8"]);
+ }
+
+ #[test]
+ fn eight_seed_upset_champion_comes_through_losers() {
+ // Seed 1 wins the WB until the grand final, but seed 2 (who lost to 1 in the
+ // WB) comes back through the LB and beats 1 twice in the GF (reset) to win.
+ let mut g = DoubleElim::new(field(&["1", "2", "3", "4", "5", "6", "7", "8"]), true);
+ // pick: seed 1 wins everything EXCEPT the two grand-final heats, which seed 2
+ // wins; otherwise lower seed wins. This makes 2 the comeback champion.
+ let pick = |id: &str, lineup: &[CompetitorRef]| -> CompetitorRef {
+ if id == "de-gf" || id == "de-gf-reset" {
+ // Whoever is seed "2" in this heat wins; both GF heats are 1 v 2.
+ if lineup.contains(&cref("2")) {
+ return cref("2");
+ }
+ }
+ chalk(id, lineup)
+ };
+ let completed = drive(&mut g, pick);
+ let ranking = g.ranking(&completed);
+ assert_eq!(ranking[0].competitor, cref("2"), "comeback champion");
+ assert_eq!(ranking[0].position, 1);
+ assert_eq!(ranking[1].competitor, cref("1"), "GF loser is runner-up");
+ assert_eq!(ranking[1].position, 2);
+ }
+
+ // --- Determinism --------------------------------------------------------
+
+ #[test]
+ fn next_is_deterministic_for_the_same_history() {
+ let mut g1 = DoubleElim::new(field(&["1", "2", "3", "4"]), true);
+ let mut g2 = DoubleElim::new(field(&["1", "2", "3", "4"]), true);
+ assert_eq!(g1.next(&[]), g2.next(&[]));
+ let completed = vec![
+ CompletedHeat::new("de-w1-h0", h2h("1", "4")),
+ CompletedHeat::new("de-w1-h1", h2h("2", "3")),
+ ];
+ assert_eq!(g1.next(&completed), g2.next(&completed));
+ }
+
+ #[test]
+ fn seeding_draw_reorders_the_bracket_deterministically() {
+ let cfg = FormatConfig::new(field(&["1", "2", "3", "4"]))
+ .with_seeding(SeedingOutcome::drawn(field(&["3", "1", "4", "2"])));
+ let mut g1 = DoubleElim::from_config(&cfg);
+ let mut g2 = DoubleElim::from_config(&cfg);
+ let s1 = g1.next(&[]);
+ assert_eq!(s1, g2.next(&[]));
+ // Drawn order 3,1,4,2 → bracket order 3,2,1,4 → WB heats (3 v 2), (1 v 4).
+ assert_eq!(
+ lineups(&s1),
+ vec![
+ ("de-w1-h0".to_string(), vec!["3".into(), "2".into()]),
+ ("de-w1-h1".to_string(), vec!["1".into(), "4".into()]),
+ ]
+ );
+ }
+
+ // --- Completion edge case -----------------------------------------------
+
+ #[test]
+ fn single_competitor_field_completes_immediately() {
+ let mut g = DoubleElim::new(field(&["1"]), true);
+ assert_eq!(g.next(&[]), GeneratorStep::Complete);
+ assert_eq!(names(&g.ranking(&[])), vec!["1"]);
+ }
+
+ // --- Provisional ranking ------------------------------------------------
+
+ #[test]
+ fn provisional_ranking_before_any_heat_is_seed_order() {
+ let g = DoubleElim::new(field(&["1", "2", "3", "4"]), true);
+ // Before any heat the field is all "still alive"; ranking falls back to seed
+ // order so it is total and stable.
+ assert_eq!(names(&g.ranking(&[])), vec!["1", "2", "3", "4"]);
+ }
+
+ // --- Registry -----------------------------------------------------------
+
+ #[test]
+ fn registry_builds_double_elim() {
+ let mut registry = FormatRegistry::new();
+ DoubleElim::register(&mut registry);
+ assert_eq!(registry.names(), vec!["double_elim"]);
+
+ let cfg = FormatConfig::new(field(&["1", "2", "3", "4"]));
+ let mut g = registry
+ .build(DoubleElim::NAME, &cfg)
+ .expect("double_elim is registered");
+ assert_eq!(
+ lineups(&g.next(&[])),
+ vec![
+ ("de-w1-h0".to_string(), vec!["1".into(), "4".into()]),
+ ("de-w1-h1".to_string(), vec!["2".into(), "3".into()]),
+ ]
+ );
+ }
+
+ #[test]
+ fn registry_reads_bracket_reset_param_off() {
+ let mut registry = FormatRegistry::new();
+ DoubleElim::register(&mut registry);
+ let cfg = FormatConfig::new(field(&["1", "2", "3", "4"])).with_param("bracket_reset", "0");
+ let mut g = registry.build(DoubleElim::NAME, &cfg).unwrap();
+ // Drive with the LB champ winning the GF; with reset off, no decider is played.
+ let mut completed = vec![
+ CompletedHeat::new("de-w1-h0", h2h("1", "4")),
+ CompletedHeat::new("de-w1-h1", h2h("2", "3")),
+ ];
+ let _ = g.next(&completed);
+ completed.push(CompletedHeat::new("de-w2-h0", h2h("1", "2")));
+ completed.push(CompletedHeat::new("de-l1-h0", h2h("4", "3")));
+ let _ = g.next(&completed);
+ completed.push(CompletedHeat::new("de-l2-h0", h2h("2", "4")));
+ let _ = g.next(&completed);
+ completed.push(CompletedHeat::new("de-gf", h2h("2", "1")));
+ assert_eq!(g.next(&completed), GeneratorStep::Complete);
+ }
+}
diff --git a/crates/engine/tests/double_elim_live.rs b/crates/engine/tests/double_elim_live.rs
new file mode 100644
index 0000000..047369c
--- /dev/null
+++ b/crates/engine/tests/double_elim_live.rs
@@ -0,0 +1,211 @@
+//! Double-elimination end-to-end test (#13) — a real bracket over real mock-RH heats.
+//!
+//! Drives a small double-elimination bracket (4 seeds) where every heat — winners
+//! bracket, losers bracket, and grand final — is a **real** dockerized RotorHazard heat
+//! run through the shared [`common::run_mock_heat`] harness. Each round the
+//! [`DoubleElim`] generator emits the [`HeatPlan`]s; for each plan we map its two
+//! competitors onto two RotorHazard nodes — the **intended winner** gets a
+//! continuously-lapping `node_csv` stream, the opponent a `dnf` plan that drops out
+//! early — run the heat, score it with [`score_events`], translate the node placements
+//! back onto the bracket's competitor refs, and feed the [`CompletedHeat`] back into the
+//! generator. We assert the bracket advances through both brackets and a single champion
+//! emerges (the top seed, given the busy stream in every heat it flies).
+//!
+//! As with the scoring / single-elim e2e the mock reads its CSV continuously (lap timing
+//! is not controllable), so this is **structural**: we rely only on "the busier node
+//! out-laps the DNF node", never on exact lap times. The harness guarantees at least one
+//! crossing per heat and the busy node supplies plenty.
+//!
+//! For RH the canonical pass `at` is **ms since race start**, so the timed clock starts
+//! at zero (`race_start = SourceTime::from_micros(0)`).
+//!
+//! Local-only class (needs Docker). DISTINCT port 5042 (heat e2e 5032, scoring 5033,
+//! single-elim 5037). Run:
+//!
+//! ```sh
+//! cargo test -p gridfpv-engine --features live --test double_elim_live -- --ignored --nocapture
+//! ```
+#![cfg(feature = "live")]
+
+mod common;
+
+use common::run_mock_heat;
+
+use gridfpv_engine::double_elim::DoubleElim;
+use gridfpv_engine::format::{CompletedHeat, Generator, GeneratorStep, HeatPlan};
+use gridfpv_engine::scoring::Placement;
+use gridfpv_engine::scoring::{HeatResult, WinCondition, score_events};
+use gridfpv_events::{CompetitorRef, SourceTime};
+use gridfpv_projection::CompetitorKey;
+use gridfpv_testkit::{NodeCsv, node_csv, plan_csv, scenarios};
+
+/// DISTINCT port for the double-elim e2e (heat e2e 5032, scoring 5033, single-elim 5037,
+/// adapters 5030/5031).
+const PORT: u16 = 5042;
+
+/// A continuously-lapping stream for the heat's intended winner.
+fn busy_stream() -> String {
+ node_csv(&NodeCsv {
+ ticks_per_lap: 2,
+ peak_rssi: 180,
+ baseline_rssi: 70,
+ })
+}
+
+/// A drop-out stream for the heat's intended loser: a couple of early laps then flat.
+fn dnf_stream() -> String {
+ plan_csv(&scenarios::dnf(2, 6))
+}
+
+/// Run one bracket heat against real RotorHazard and return its scored [`HeatResult`]
+/// **expressed in the bracket's own competitor refs** (same remapping shape as the
+/// single-elim e2e: intended winner on node 0 with the busy stream, others DNF).
+fn run_bracket_heat(plan: &HeatPlan, winner: &CompetitorRef) -> CompletedHeat {
+ let mut ordered: Vec<&CompetitorRef> = Vec::with_capacity(plan.lineup.len());
+ ordered.push(winner);
+ for c in &plan.lineup {
+ if c != winner {
+ ordered.push(c);
+ }
+ }
+
+ let scenario: Vec<(usize, String)> = ordered
+ .iter()
+ .enumerate()
+ .map(|(node, _)| {
+ let stream = if node == 0 {
+ busy_stream()
+ } else {
+ dnf_stream()
+ };
+ (node, stream)
+ })
+ .collect();
+
+ let log = run_mock_heat(PORT, &plan.heat.0, &scenario);
+ let race_start = SourceTime::from_micros(0);
+ let scored = score_events(
+ &log,
+ WinCondition::Timed {
+ window_micros: 10 * 60 * 1_000_000,
+ },
+ race_start,
+ );
+
+ // Translate node-seat placements back onto the bracket's competitor refs by lineup
+ // position; nodes that produced no live-window passes are parked behind the rest.
+ let mut places: Vec = Vec::new();
+ let mut seen: Vec = vec![false; ordered.len()];
+ for place in &scored.places {
+ if let Some(node) = node_index(&place.competitor) {
+ if node < ordered.len() {
+ seen[node] = true;
+ places.push(remap(place, ordered[node]));
+ }
+ }
+ }
+ let mut next_pos = places.len() as u32 + 1;
+ for (node, present) in seen.iter().enumerate() {
+ if !present {
+ places.push(Placement {
+ competitor: CompetitorKey {
+ adapter: scored
+ .places
+ .first()
+ .map(|p| p.competitor.adapter.clone())
+ .unwrap_or_else(|| gridfpv_events::AdapterId("rotorhazard".into())),
+ competitor: ordered[node].clone(),
+ },
+ position: next_pos,
+ laps: 0,
+ metric: gridfpv_engine::scoring::Metric::LastLapAt(None),
+ });
+ next_pos += 1;
+ }
+ }
+
+ CompletedHeat::new(plan.heat.0.clone(), HeatResult { places })
+}
+
+/// The seat node index behind a `node-{i}` competitor ref, if it has that shape.
+fn node_index(key: &CompetitorKey) -> Option {
+ key.competitor.0.strip_prefix("node-")?.parse().ok()
+}
+
+/// Rebuild a placement under the bracket competitor `as_ref`, preserving position/laps.
+fn remap(place: &Placement, as_ref: &CompetitorRef) -> Placement {
+ Placement {
+ competitor: CompetitorKey {
+ adapter: place.competitor.adapter.clone(),
+ competitor: as_ref.clone(),
+ },
+ position: place.position,
+ laps: place.laps,
+ metric: place.metric,
+ }
+}
+
+#[test]
+#[ignore = "requires Docker (spins up dockerized RotorHazard and drives full heats)"]
+fn four_seed_double_elim_runs_to_a_single_champion_over_real_heats() {
+ let field: Vec = ["1", "2", "3", "4"]
+ .iter()
+ .map(|n| CompetitorRef(n.to_string()))
+ .collect();
+ // Head-to-head double elimination. The top seed is the intended winner of every
+ // heat it flies (winners, and the grand final), so it should emerge as champion
+ // without ever needing a bracket reset.
+ let intended_winner = CompetitorRef("1".into());
+ let mut generator = DoubleElim::new(field, true);
+
+ let mut completed: Vec = Vec::new();
+ let mut rounds = 0;
+ while let GeneratorStep::Run(heats) = generator.next(&completed) {
+ rounds += 1;
+ assert!(rounds < 16, "bracket should converge well within 16 rounds");
+ assert!(!heats.is_empty(), "a Run step must carry at least one heat");
+ for plan in &heats {
+ // The intended winner wins any heat it is in; otherwise the highest seed
+ // present (smallest numeric ref) takes the busy stream and wins.
+ let winner = if plan.lineup.contains(&intended_winner) {
+ intended_winner.clone()
+ } else {
+ plan.lineup
+ .iter()
+ .min_by_key(|c| c.0.parse::().unwrap_or(u32::MAX))
+ .cloned()
+ .expect("non-empty lineup")
+ };
+ eprintln!(
+ "double-elim e2e: heat {} lineup {:?} intended winner {}",
+ plan.heat.0,
+ plan.lineup.iter().map(|c| &c.0).collect::>(),
+ winner.0
+ );
+ completed.push(run_bracket_heat(plan, &winner));
+ }
+ }
+
+ // The bracket completed: exactly one champion at the top of the final ranking.
+ let ranking = generator.ranking(&completed);
+ assert_eq!(ranking.len(), 4, "every seed should appear in the ranking");
+ assert_eq!(ranking[0].position, 1, "there is a single champion");
+ assert_eq!(
+ ranking[0].competitor, intended_winner,
+ "the seed given the busy stream in every heat wins the bracket"
+ );
+ assert_eq!(
+ ranking.iter().filter(|e| e.position == 1).count(),
+ 1,
+ "exactly one competitor holds first place"
+ );
+ // The top seed never loses, so no bracket-reset heat should have been played.
+ assert!(
+ !completed.iter().any(|c| c.heat.0 == "de-gf-reset"),
+ "an undefeated WB champion needs no reset"
+ );
+ eprintln!(
+ "double-elim e2e: champion {} after {} rounds",
+ ranking[0].competitor.0, rounds
+ );
+}
From cf3bcc11f12baa65519f973ca5ae12b858d1fb82 Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 17:57:24 +0000
Subject: [PATCH 037/362] Wire the new generator e2es into cargo xtask live
(#68, #69, #70)
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
xtask/src/main.rs | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index b02ef96..7dea902 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -171,6 +171,10 @@ fn live() -> bool {
let zippyq_live = target("gridfpv-engine", "zippyq_live", true);
let multiclass_live = target("gridfpv-engine", "multiclass_live", true);
let full_event_live = target("gridfpv-engine", "full_event_live", true);
+ // Additional format generators (#68 double-elim, #69 round-robin, #70 multi-main).
+ let double_elim_live = target("gridfpv-engine", "double_elim_live", true);
+ let round_robin_live = target("gridfpv-engine", "round_robin_live", true);
+ let multi_main_live = target("gridfpv-engine", "multi_main_live", true);
// The protocol server's mock-RH e2e: full event → server log → protocol client (#47).
let server_e2e = target("gridfpv-server", "full_event_live", true);
ws && live_rh
@@ -184,6 +188,9 @@ fn live() -> bool {
&& zippyq_live
&& multiclass_live
&& full_event_live
+ && double_elim_live
+ && round_robin_live
+ && multi_main_live
&& server_e2e
}
From 7515ecf21aceecb12bffe1128c34548663d0023f Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 18:10:19 +0000
Subject: [PATCH 038/362] Apply penalties and heat-void in scoring (#13)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`Event::PenaltyApplied` (Disqualify / TimeAdded) and `Event::HeatVoided`
were appended by the control path but never folded into a `HeatResult`,
so a DQ, time penalty, or voided heat left the score unchanged. Apply
them in the engine scorer.
Scoring entry point: `score_with_adjudications(events, condition,
race_start)` distils a heat's log into `Adjudications` (accumulated
per-competitor TimeAdded, the DQ set, the voided flag) and folds them on
top of the pure on-track ranking. `score_events` now delegates to it, and
`event::score_marshaled` applies the same adjudications on the corrected
pass stream so penalties and marshaling compose. Per-penalty behavior:
- Disqualify: the competitor is ranked after every non-disqualified one
(DQ flag is the primary sort key) and flagged, regardless of on-track
result; their laps/metric stay visible.
- TimeAdded: accumulates per competitor and worsens the deciding time in
the rank key — the Timed tie-break time, the FirstToLaps reach time,
the BestLap duration, the BestConsecutive sum. The on-track `metric` is
left unchanged for display.
- HeatVoided: nullifies the heat by flagging `HeatResult.voided`; the
places are still scored so the standing remains visible.
All deterministic — pure folds, no clock or RNG.
Additive fields: `Placement.disqualified: bool` and `HeatResult.voided:
bool`, both `#[serde(default, skip_serializing_if = ..)]` (so they render
as optional in TS and clean results are unchanged on the wire), with a
`Default` path. Regenerated `bindings/Placement.ts` and
`bindings/HeatResult.ts`.
Ripple: the new fields broke every `Placement` / `HeatResult`
struct-literal. Updated the format generators' table-test fixtures
(single_elim, double_elim, round_robin, multi_main, zippyq, timed_qual,
format), schedule, event, and the `*_live` integration tests
(single_elim_live, double_elim_live, multi_main_live, full_event_live) —
all via `..Default::default()` so future additions don't ripple again. No
generator logic changed.
Tests: exact table tests in scoring for DQ-drops-leader-to-last, two-DQ,
TimeAdded reordering a best-lap and a timed tie-break, TimeAdded
accumulation, HeatVoided flagging, clean-log equivalence, and a
penalty+marshaling compose case. `cargo xtask ci` green (incl. gen
drift); engine clippy + `--features live` build/clippy clean.
Part of #13 (single-race penalties/heat-void).
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
bindings/HeatResult.ts | 9 +-
bindings/Placement.ts | 11 +-
crates/engine/src/double_elim.rs | 2 +
crates/engine/src/event.rs | 19 +-
crates/engine/src/format.rs | 2 +
crates/engine/src/multi_main.rs | 2 +
crates/engine/src/round_robin.rs | 2 +
crates/engine/src/schedule.rs | 5 +-
crates/engine/src/scoring.rs | 519 +++++++++++++++++++++---
crates/engine/src/single_elim.rs | 2 +
crates/engine/src/timed_qual.rs | 4 +
crates/engine/src/zippyq.rs | 2 +
crates/engine/tests/double_elim_live.rs | 10 +-
crates/engine/tests/full_event_live.rs | 7 +-
crates/engine/tests/multi_main_live.rs | 10 +-
crates/engine/tests/single_elim_live.rs | 10 +-
16 files changed, 553 insertions(+), 63 deletions(-)
diff --git a/bindings/HeatResult.ts b/bindings/HeatResult.ts
index 1811675..02b99d1 100644
--- a/bindings/HeatResult.ts
+++ b/bindings/HeatResult.ts
@@ -12,4 +12,11 @@ export type HeatResult = {
/**
* Placements in finishing order (ties adjacent, sharing a position).
*/
-places: Array, };
+places: Array,
+/**
+ * Whether the whole heat was **voided** by an adjudication
+ * ([`gridfpv_events::Event::HeatVoided`]). A voided result is nullified: its
+ * `places` are still scored (so the on-track standing is visible) but the heat does
+ * not count. Defaults to `false` and is omitted from the wire when false.
+ */
+voided?: boolean, };
diff --git a/bindings/Placement.ts b/bindings/Placement.ts
index 32cf523..36131b8 100644
--- a/bindings/Placement.ts
+++ b/bindings/Placement.ts
@@ -28,4 +28,13 @@ laps: number,
/**
* The condition-specific deciding metric for this competitor.
*/
-metric: Metric, };
+metric: Metric,
+/**
+ * Whether this competitor was **disqualified** by an adjudication
+ * ([`gridfpv_events::Penalty::Disqualify`] via
+ * [`gridfpv_events::Event::PenaltyApplied`]). A disqualified competitor is ranked
+ * **after every non-disqualified competitor**, regardless of their on-track result
+ * (see [`score_with_adjudications`]). Defaults to `false` and is omitted from the
+ * wire when false, so clean results carry no extra bytes.
+ */
+disqualified?: boolean, };
diff --git a/crates/engine/src/double_elim.rs b/crates/engine/src/double_elim.rs
index 16e1b72..81ed31a 100644
--- a/crates/engine/src/double_elim.rs
+++ b/crates/engine/src/double_elim.rs
@@ -517,8 +517,10 @@ mod tests {
position: *position,
laps: *laps,
metric: Metric::LastLapAt(None),
+ ..Default::default()
})
.collect(),
+ ..Default::default()
}
}
diff --git a/crates/engine/src/event.rs b/crates/engine/src/event.rs
index 8340534..2028181 100644
--- a/crates/engine/src/event.rs
+++ b/crates/engine/src/event.rs
@@ -47,7 +47,7 @@ use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::format::{CompletedHeat, Generator, GeneratorStep, HeatPlan, RankEntry, advance_top_n};
-use crate::scoring::{HeatResult, WinCondition, score};
+use crate::scoring::{HeatResult, WinCondition, apply_adjudications};
/// Turn one planned heat into its scored result. The single injected dependency the
/// event driver needs: it owns *how* a heat is run (replay a fixture log, drive real
@@ -168,8 +168,14 @@ pub fn run_event(
/// with [`gridfpv_projection::lap_list_marshaled`] by construction (both fold via the
/// single source of truth).
///
+/// On top of the marshaling fold, this also applies the heat's **adjudications**
+/// ([`gridfpv_events::Event::PenaltyApplied`] / [`gridfpv_events::Event::HeatVoided`], #13):
+/// a `Disqualify` sinks a competitor below the field (flagging it), a `TimeAdded` worsens
+/// their deciding time, and a `HeatVoided` flags the whole result voided — so a full event
+/// run reflects penalties and heat-voids, not just lap corrections.
+///
/// `race_start` is the shared race clock for [`WinCondition::Timed`] (ignored by the
-/// qualifying / first-to-N conditions), matching [`score`].
+/// qualifying / first-to-N conditions), matching [`crate::scoring::score`].
pub fn score_marshaled(
events: &[Event],
condition: WinCondition,
@@ -180,7 +186,10 @@ pub fn score_marshaled(
// corrected lap-gate passes it returns. The scorer re-groups/re-orders by competitor.
let corrected =
gridfpv_projection::corrected_passes(events.iter().enumerate().map(|(i, e)| (i as u64, e)));
- score(&corrected, condition, race_start)
+ // Penalties / heat-void are a *separate* fold from the marshaling corrections above:
+ // apply them on the corrected pass stream so an adjudicated, marshaled heat reflects
+ // both (#13). A log with no penalties scores exactly as before.
+ apply_adjudications(&corrected, condition, race_start, events)
}
#[cfg(test)]
@@ -309,8 +318,10 @@ mod tests {
position: (i as u32) + 1,
laps: 0,
metric: Metric::BestLapMicros(*micros),
+ ..Default::default()
})
.collect(),
+ ..Default::default()
}
}
@@ -328,8 +339,10 @@ mod tests {
position: *position,
laps: *laps,
metric: Metric::LastLapAt(None),
+ ..Default::default()
})
.collect(),
+ ..Default::default()
}
}
diff --git a/crates/engine/src/format.rs b/crates/engine/src/format.rs
index e76436c..a7e147c 100644
--- a/crates/engine/src/format.rs
+++ b/crates/engine/src/format.rs
@@ -681,8 +681,10 @@ mod tests {
position: *position,
laps: *laps,
metric: Metric::LastLapAt(None),
+ ..Default::default()
})
.collect(),
+ ..Default::default()
}
}
diff --git a/crates/engine/src/multi_main.rs b/crates/engine/src/multi_main.rs
index 0ff33ba..07e2a28 100644
--- a/crates/engine/src/multi_main.rs
+++ b/crates/engine/src/multi_main.rs
@@ -214,8 +214,10 @@ mod tests {
position: *position,
laps: *laps,
metric: Metric::LastLapAt(None),
+ ..Default::default()
})
.collect(),
+ ..Default::default()
}
}
diff --git a/crates/engine/src/round_robin.rs b/crates/engine/src/round_robin.rs
index 3ffd284..dd0d31c 100644
--- a/crates/engine/src/round_robin.rs
+++ b/crates/engine/src/round_robin.rs
@@ -309,8 +309,10 @@ mod tests {
position: *position,
laps: *laps,
metric: Metric::LastLapAt(None),
+ ..Default::default()
})
.collect(),
+ ..Default::default()
}
}
diff --git a/crates/engine/src/schedule.rs b/crates/engine/src/schedule.rs
index 3877cf9..f54b565 100644
--- a/crates/engine/src/schedule.rs
+++ b/crates/engine/src/schedule.rs
@@ -431,7 +431,10 @@ mod tests {
/// A trivial empty [`HeatResult`] for the scheduler tests, which only need *a*
/// result to feed back — the scheduler never inspects its contents.
fn empty_result() -> HeatResult {
- HeatResult { places: Vec::new() }
+ HeatResult {
+ places: Vec::new(),
+ ..Default::default()
+ }
}
// --- Frequency allocation -----------------------------------------------
diff --git a/crates/engine/src/scoring.rs b/crates/engine/src/scoring.rs
index 29b9724..77dd06b 100644
--- a/crates/engine/src/scoring.rs
+++ b/crates/engine/src/scoring.rs
@@ -39,7 +39,9 @@
//! about the function distinguishes a finished heat from an in-progress one.
#![forbid(unsafe_code)]
-use gridfpv_events::{Event, Pass, SourceTime};
+use std::collections::{BTreeMap, BTreeSet};
+
+use gridfpv_events::{CompetitorRef, Event, Pass, Penalty, SourceTime};
use gridfpv_projection::CompetitorKey;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
@@ -102,6 +104,41 @@ pub struct Placement {
pub laps: u32,
/// The condition-specific deciding metric for this competitor.
pub metric: Metric,
+ /// Whether this competitor was **disqualified** by an adjudication
+ /// ([`gridfpv_events::Penalty::Disqualify`] via
+ /// [`gridfpv_events::Event::PenaltyApplied`]). A disqualified competitor is ranked
+ /// **after every non-disqualified competitor**, regardless of their on-track result
+ /// (see [`score_with_adjudications`]). Defaults to `false` and is omitted from the
+ /// wire when false, so clean results carry no extra bytes.
+ #[serde(default, skip_serializing_if = "is_false")]
+ pub disqualified: bool,
+}
+
+/// serde `skip_serializing_if` predicate: omit additive `bool` flags when false so a
+/// clean result serialises exactly as it did before these fields existed.
+#[allow(clippy::trivially_copy_pass_by_ref)]
+fn is_false(b: &bool) -> bool {
+ !*b
+}
+
+impl Default for Placement {
+ /// An empty placeholder placement. Exists so constructors (chiefly test fixtures)
+ /// can spread `..Default::default()` and only set the fields they care about, which
+ /// keeps additive [`Placement`] fields from rippling into every struct-literal again
+ /// (a later field defaults rather than breaking the build). `CompetitorKey` has no
+ /// `Default` of its own, so this supplies an empty one; callers always overwrite it.
+ fn default() -> Self {
+ Placement {
+ competitor: CompetitorKey {
+ adapter: gridfpv_events::AdapterId(String::new()),
+ competitor: CompetitorRef(String::new()),
+ },
+ position: 0,
+ laps: 0,
+ metric: Metric::LastLapAt(None),
+ disqualified: false,
+ }
+ }
}
/// The condition-specific value a [`Placement`] was ranked on, kept for display and
@@ -127,11 +164,17 @@ pub enum Metric {
/// Ties share a `position` (see [`Placement::position`]). The order within a tie
/// group is still deterministic — competitors are ordered by [`CompetitorKey`] as
/// the final, total tie-break — but their `position` numbers are equal.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "bindings/")]
pub struct HeatResult {
/// Placements in finishing order (ties adjacent, sharing a position).
pub places: Vec,
+ /// Whether the whole heat was **voided** by an adjudication
+ /// ([`gridfpv_events::Event::HeatVoided`]). A voided result is nullified: its
+ /// `places` are still scored (so the on-track standing is visible) but the heat does
+ /// not count. Defaults to `false` and is omitted from the wire when false.
+ #[serde(default, skip_serializing_if = "is_false")]
+ pub voided: bool,
}
/// A single completed lap, with both its absolute completion time and its duration.
@@ -201,19 +244,96 @@ impl Run {
/// a position. Called on a partial pass list this is the **provisional / live
/// ranking** (see the module docs).
pub fn score(passes: &[Pass], condition: WinCondition, race_start: SourceTime) -> HeatResult {
- let runs = Run::group(passes);
- match condition {
- WinCondition::Timed { window_micros } => score_timed(runs, race_start, window_micros),
- WinCondition::FirstToLaps { n } => score_first_to_laps(runs, n),
- WinCondition::BestLap => score_best_lap(runs),
- WinCondition::BestConsecutive { n } => score_best_consecutive(runs, n),
+ score_inner(passes, condition, race_start, &Adjudications::default())
+}
+
+/// The adjudications a heat's [`Event::PenaltyApplied`] / [`Event::HeatVoided`] log
+/// distils to, applied on top of the pure on-track scoring (race-engine.html §7.1, #13).
+///
+/// Built by [`Adjudications::collect`] from a heat's events; pure data, so the same log
+/// always yields the same adjudications and the scored result replays identically (no
+/// clock or RNG). Penalties are keyed by [`CompetitorRef`] because that is what the
+/// penalty events carry; a heat's events are all one heat, so the ref is unambiguous.
+#[derive(Debug, Clone, Default)]
+struct Adjudications {
+ /// Per-competitor microseconds added to their deciding time, **accumulated** across
+ /// every [`Penalty::TimeAdded`] for that competitor (multiple penalties stack).
+ time_added: BTreeMap,
+ /// Competitors disqualified by a [`Penalty::Disqualify`] — ranked after everyone not
+ /// disqualified and flagged [`Placement::disqualified`].
+ disqualified: BTreeSet,
+ /// Whether the whole heat was voided ([`Event::HeatVoided`]).
+ voided: bool,
+}
+
+impl Adjudications {
+ /// Distil a heat's event log into its adjudications. Ignores everything that is not a
+ /// [`Event::PenaltyApplied`] / [`Event::HeatVoided`]; `TimeAdded` penalties accumulate,
+ /// any `Disqualify` disqualifies, any `HeatVoided` voids. Deterministic — pure fold.
+ fn collect(events: &[Event]) -> Self {
+ let mut adj = Adjudications::default();
+ for event in events {
+ match event {
+ Event::PenaltyApplied {
+ competitor,
+ penalty,
+ ..
+ } => match penalty {
+ Penalty::Disqualify => {
+ adj.disqualified.insert(competitor.clone());
+ }
+ Penalty::TimeAdded { micros } => {
+ *adj.time_added.entry(competitor.clone()).or_default() += *micros;
+ }
+ },
+ Event::HeatVoided { .. } => adj.voided = true,
+ _ => {}
+ }
+ }
+ adj
+ }
+
+ /// Microseconds to add to `competitor`'s deciding time (0 if none).
+ fn added(&self, competitor: &CompetitorRef) -> i64 {
+ self.time_added.get(competitor).copied().unwrap_or_default()
+ }
+
+ /// Whether `competitor` was disqualified.
+ fn is_dq(&self, competitor: &CompetitorRef) -> bool {
+ self.disqualified.contains(competitor)
}
}
-/// Convenience wrapper over a canonical [`Event`] log: filters to lap-gate
-/// [`Pass`]es and scores them, so callers holding a heat's event log (e.g. from the
-/// mock-RH harness) need not pre-filter.
-pub fn score_events(
+/// Score `passes` under `condition`, then apply `adj`'s adjudications: [`Penalty::TimeAdded`]
+/// worsens the deciding time used to rank a competitor, [`Penalty::Disqualify`] sinks a
+/// competitor below every non-disqualified one (flagging [`Placement::disqualified`]), and
+/// [`Event::HeatVoided`] flags the whole [`HeatResult`] voided.
+fn score_inner(
+ passes: &[Pass],
+ condition: WinCondition,
+ race_start: SourceTime,
+ adj: &Adjudications,
+) -> HeatResult {
+ let runs = Run::group(passes);
+ let mut result = match condition {
+ WinCondition::Timed { window_micros } => score_timed(runs, race_start, window_micros, adj),
+ WinCondition::FirstToLaps { n } => score_first_to_laps(runs, n, adj),
+ WinCondition::BestLap => score_best_lap(runs, adj),
+ WinCondition::BestConsecutive { n } => score_best_consecutive(runs, n, adj),
+ };
+ result.voided = adj.voided;
+ result
+}
+
+/// Score a heat's event log under `condition`, applying its **adjudications**
+/// ([`Event::PenaltyApplied`] / [`Event::HeatVoided`], #13).
+///
+/// This is the single home of penalty / heat-void application. It scores the raw
+/// lap-gate passes the log carries, then folds in the heat's adjudications. Marshaling
+/// corrections (void/insert/adjust) are a *separate* fold ([`crate::event::score_marshaled`]);
+/// that path calls [`apply_adjudications`] on the corrected stream so penalties and
+/// marshaling compose without either fold knowing about the other.
+pub fn score_with_adjudications(
events: &[Event],
condition: WinCondition,
race_start: SourceTime,
@@ -225,45 +345,122 @@ pub fn score_events(
_ => None,
})
.collect();
- score(&passes, condition, race_start)
+ score_inner(
+ &passes,
+ condition,
+ race_start,
+ &Adjudications::collect(events),
+ )
+}
+
+/// Score already-grouped/corrected `passes`, applying the adjudications carried by `events`.
+///
+/// Used by the marshaling-aware path ([`crate::event::score_marshaled`]): it has already
+/// folded void/insert/adjust into a corrected pass stream, so here we only re-derive the
+/// adjudications from the same log and apply them — penalties and heat-void compose with
+/// marshaling without either fold knowing about the other.
+pub(crate) fn apply_adjudications(
+ passes: &[Pass],
+ condition: WinCondition,
+ race_start: SourceTime,
+ events: &[Event],
+) -> HeatResult {
+ score_inner(
+ passes,
+ condition,
+ race_start,
+ &Adjudications::collect(events),
+ )
+}
+
+/// Convenience wrapper over a canonical [`Event`] log: filters to lap-gate [`Pass`]es,
+/// scores them, and applies any adjudications the log carries
+/// ([`Event::PenaltyApplied`] / [`Event::HeatVoided`]) — so callers holding a heat's
+/// event log (e.g. from the mock-RH harness) get the fully-adjudicated result without
+/// pre-filtering. A log with no penalties scores exactly as [`score`] does.
+pub fn score_events(
+ events: &[Event],
+ condition: WinCondition,
+ race_start: SourceTime,
+) -> HeatResult {
+ score_with_adjudications(events, condition, race_start)
}
-/// Assemble a [`HeatResult`] from `(competitor, laps, metric, rank_key)` rows.
+/// Assemble a [`HeatResult`] from `(competitor, laps, metric, rank_key)` rows, applying
+/// `adj`'s disqualifications.
///
-/// `rank_key` is a total, deterministic ordering key (smaller = better). Rows are
-/// sorted by `(rank_key, competitor)` so the competitor key is the final tie-break
-/// and the order is always total; competitors whose `rank_key` is *equal* (the part
-/// the condition cares about) share a position, with the next distinct group's
-/// position skipping past them (1, 2, 2, 4).
-fn rank(rows: Vec<(CompetitorKey, u32, Metric, K)>) -> HeatResult {
- let mut rows = rows;
- // Total order: rank key first, competitor key as the final deterministic
- // tie-break so two rows are never "equal" for sorting purposes.
- rows.sort_by(|a, b| a.3.cmp(&b.3).then_with(|| a.0.cmp(&b.0)));
+/// `rank_key` is a total, deterministic ordering key (smaller = better; any
+/// [`Penalty::TimeAdded`] is already folded into it by the per-condition scorer). Rows
+/// are sorted by `(disqualified, rank_key, competitor)`: **disqualified competitors sink
+/// below every non-disqualified one** regardless of on-track result, then within each
+/// group the rank key orders and the competitor key is the final deterministic tie-break.
+/// Competitors whose `(disqualified, rank_key)` is *equal* share a position, with the next
+/// distinct group's position skipping past them (1, 2, 2, 4). DQ'd placements carry
+/// [`Placement::disqualified`] `= true`.
+fn rank(
+ rows: Vec<(CompetitorKey, u32, Metric, K)>,
+ adj: &Adjudications,
+) -> HeatResult {
+ // Pair each row with its DQ flag; the flag is the *primary* sort key (false < true),
+ // so every disqualified competitor ranks after every non-disqualified one.
+ let mut rows: Vec<(bool, CompetitorKey, u32, Metric, K)> = rows
+ .into_iter()
+ .map(|(competitor, laps, metric, key)| {
+ (
+ adj.is_dq(&competitor.competitor),
+ competitor,
+ laps,
+ metric,
+ key,
+ )
+ })
+ .collect();
+ // Total order: DQ first, then rank key, then competitor key as the final
+ // deterministic tie-break so two rows are never "equal" for sorting purposes.
+ rows.sort_by(|a, b| {
+ a.0.cmp(&b.0)
+ .then_with(|| a.4.cmp(&b.4))
+ .then_with(|| a.1.cmp(&b.1))
+ });
let mut places = Vec::with_capacity(rows.len());
- let mut prev_key: Option = None;
+ // A position groups by the *ranking* identity that competitors share: the DQ flag
+ // plus the rank key. A DQ'd competitor never shares a position with a non-DQ'd one.
+ let mut prev_group: Option<(bool, K)> = None;
let mut position = 0u32;
- for (index, (competitor, laps, metric, key)) in rows.into_iter().enumerate() {
- // A new position whenever the *ranking* key changes; equal ranking keys
- // share the position of the first row in their group.
- if prev_key.as_ref() != Some(&key) {
+ for (index, (disqualified, competitor, laps, metric, key)) in rows.into_iter().enumerate() {
+ let group = (disqualified, key);
+ if prev_group.as_ref() != Some(&group) {
position = (index as u32) + 1;
- prev_key = Some(key.clone());
+ prev_group = Some(group);
}
places.push(Placement {
competitor,
position,
laps,
metric,
+ disqualified,
});
}
- HeatResult { places }
+ HeatResult {
+ places,
+ voided: false,
+ }
}
/// Timed: count laps whose completing pass is strictly before the cutoff, rank by
/// count desc then earlier last-counted-lap completion.
-fn score_timed(runs: Vec, race_start: SourceTime, window_micros: i64) -> HeatResult {
+///
+/// `TimeAdded` here is a **pure lap-count** condition, so the penalty cannot change the
+/// lap count; per the recorded rule it is folded into the **tie-break time** (the last
+/// counted lap's completion), worsening a penalised competitor's standing against others
+/// on the same lap count without inventing or removing laps.
+fn score_timed(
+ runs: Vec,
+ race_start: SourceTime,
+ window_micros: i64,
+ adj: &Adjudications,
+) -> HeatResult {
let cutoff = race_start.micros + window_micros;
let rows = runs
.into_iter()
@@ -277,22 +474,29 @@ fn score_timed(runs: Vec, race_start: SourceTime, window_micros: i64) -> He
.collect();
let count = counted.len() as u32;
let last_at = counted.last().map(|lap| lap.at);
+ let added = adj.added(&run.competitor.competitor);
// Rank key: fewer laps is worse (negate count so smaller = better), then
- // earlier last-lap completion is better. `i64::MAX` for "no lap" sorts a
- // lapless competitor behind everyone with a lap at the same (zero) count.
+ // earlier last-lap completion is better, with any TimeAdded worsening it.
+ // `i64::MAX` for "no lap" sorts a lapless competitor behind everyone with a
+ // lap at the same (zero) count.
let key = (
-(count as i64),
- last_at.map(|t| t.micros).unwrap_or(i64::MAX),
+ last_at
+ .map(|t| t.micros.saturating_add(added))
+ .unwrap_or(i64::MAX),
);
(run.competitor, count, Metric::LastLapAt(last_at), key)
})
.collect();
- rank(rows)
+ rank(rows, adj)
}
/// First-to-N: rank by who reached lap `n` earliest; non-reachers after, by laps
/// desc then last-lap completion.
-fn score_first_to_laps(runs: Vec, n: u32) -> HeatResult {
+///
+/// `TimeAdded` worsens the **deciding time**: a reacher's reach-time and a non-reacher's
+/// last-lap tie-break time both shift later by the accumulated penalty.
+fn score_first_to_laps(runs: Vec, n: u32, adj: &Adjudications) -> HeatResult {
let rows = runs
.into_iter()
.map(|run| {
@@ -304,27 +508,33 @@ fn score_first_to_laps(runs: Vec, n: u32) -> HeatResult {
None
};
let last_at = run.laps.last().map(|lap| lap.at);
+ let added = adj.added(&run.competitor.competitor);
// Reachers (group 0) sort ahead of non-reachers (group 1). Within
- // reachers, earlier reach-time wins. Within non-reachers, more laps then
- // earlier last-lap completion.
+ // reachers, earlier (penalty-worsened) reach-time wins. Within non-reachers,
+ // more laps then earlier (penalty-worsened) last-lap completion.
let key = match reached_at {
- Some(t) => (0i8, t.micros, 0i64, 0i64),
+ Some(t) => (0i8, t.micros.saturating_add(added), 0i64, 0i64),
None => (
1i8,
0,
-(count as i64),
- last_at.map(|t| t.micros).unwrap_or(i64::MAX),
+ last_at
+ .map(|t| t.micros.saturating_add(added))
+ .unwrap_or(i64::MAX),
),
};
(run.competitor, count, Metric::ReachedAt(reached_at), key)
})
.collect();
- rank(rows)
+ rank(rows, adj)
}
/// Best single lap: rank by smallest lap duration; ties break by when that lap was
/// set; no-lap competitors last.
-fn score_best_lap(runs: Vec) -> HeatResult {
+///
+/// `TimeAdded` worsens the **deciding time** by lengthening the best-lap duration the
+/// competitor is ranked on (the on-track `metric` is left unchanged for display).
+fn score_best_lap(runs: Vec, adj: &Adjudications) -> HeatResult {
let rows = runs
.into_iter()
.map(|run| {
@@ -340,10 +550,14 @@ fn score_best_lap(runs: Vec) -> HeatResult {
})
.copied();
let best_micros = best.map(|lap| lap.duration_micros);
- // Smaller duration is better; `i64::MAX` parks no-lap competitors last.
- // Second key (set-time) makes equal-duration laps a total order.
+ let added = adj.added(&run.competitor.competitor);
+ // Smaller (penalty-lengthened) duration is better; `i64::MAX` parks no-lap
+ // competitors last. Second key (set-time) makes equal-duration laps a total
+ // order.
let key = (
- best_micros.unwrap_or(i64::MAX),
+ best_micros
+ .map(|d| d.saturating_add(added))
+ .unwrap_or(i64::MAX),
best.map(|lap| lap.at.micros).unwrap_or(i64::MAX),
);
(
@@ -354,13 +568,16 @@ fn score_best_lap(runs: Vec) -> HeatResult {
)
})
.collect();
- rank(rows)
+ rank(rows, adj)
}
/// Best consecutive `n`: rank by smallest sum over any `n` consecutive laps; ties
/// break by the completion time of the window's last lap; under-`n` competitors
/// last.
-fn score_best_consecutive(runs: Vec, n: u32) -> HeatResult {
+///
+/// `TimeAdded` worsens the **deciding time** by adding to the best window's sum the
+/// competitor is ranked on (the on-track `metric` is left unchanged for display).
+fn score_best_consecutive(runs: Vec, n: u32, adj: &Adjudications) -> HeatResult {
let n = n.max(1) as usize;
let rows = runs
.into_iter()
@@ -378,10 +595,11 @@ fn score_best_consecutive(runs: Vec, n: u32) -> HeatResult {
})
.min_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
let best_sum = best.map(|(sum, _)| sum);
- // Smaller sum is better; no window (fewer than `n` laps) sorts last,
- // ordered among themselves by lap count desc.
+ let added = adj.added(&run.competitor.competitor);
+ // Smaller (penalty-lengthened) sum is better; no window (fewer than `n` laps)
+ // sorts last, ordered among themselves by lap count desc.
let key = match best {
- Some((sum, end_at)) => (0i8, sum, end_at, 0i64),
+ Some((sum, end_at)) => (0i8, sum.saturating_add(added), end_at, 0i64),
None => (1i8, 0, 0, -(count as i64)),
};
(
@@ -392,13 +610,13 @@ fn score_best_consecutive(runs: Vec, n: u32) -> HeatResult {
)
})
.collect();
- rank(rows)
+ rank(rows, adj)
}
#[cfg(test)]
mod tests {
use super::*;
- use gridfpv_events::{AdapterId, CompetitorRef, GateIndex};
+ use gridfpv_events::{AdapterId, CompetitorRef, GateIndex, HeatId};
const ADAPTER: &str = "vd";
@@ -796,4 +1014,199 @@ mod tests {
let r = score(&passes, WinCondition::BestLap, start());
assert_eq!(r.places.len(), 2);
}
+
+ // --- Adjudications: penalties & heat-void (#13) -------------------------
+
+ /// The lap-gate passes of a whole run, wrapped as `Event::Pass`es.
+ fn pass_events(competitor: &str, ats: &[i64]) -> Vec {
+ run(competitor, ats).into_iter().map(Event::Pass).collect()
+ }
+
+ /// A `PenaltyApplied` for `competitor` in a fixed heat.
+ fn penalty(competitor: &str, penalty: Penalty) -> Event {
+ Event::PenaltyApplied {
+ heat: HeatId("h".into()),
+ competitor: CompetitorRef(competitor.into()),
+ penalty,
+ }
+ }
+
+ #[test]
+ fn clean_log_score_events_matches_pure_score() {
+ // No adjudications: the adjudicated path equals the pure scorer exactly, and
+ // the additive flags are all false.
+ let events = pass_events("A", &[0, 2_000_000, 4_000_000]);
+ let cond = WinCondition::BestLap;
+ let r = score_events(&events, cond, start());
+ let pure = score(&run("A", &[0, 2_000_000, 4_000_000]), cond, start());
+ assert_eq!(r, pure);
+ assert!(!r.voided);
+ assert!(r.places.iter().all(|p| !p.disqualified));
+ }
+
+ #[test]
+ fn disqualify_drops_leader_to_last_and_shifts_others_up() {
+ // Timed: A leads (3 laps), B (2), C (1) → A,B,C. DQ A: A sinks to last, B and C
+ // shift up, and A is flagged disqualified.
+ let mut events = pass_events("A", &[0, 5_000_000, 10_000_000, 15_000_000]);
+ events.extend(pass_events("B", &[0, 6_000_000, 13_000_000]));
+ events.extend(pass_events("C", &[0, 7_000_000]));
+ events.push(penalty("A", Penalty::Disqualify));
+
+ let r = score_events(
+ &events,
+ WinCondition::Timed {
+ window_micros: 60_000_000,
+ },
+ start(),
+ );
+
+ assert_eq!(place(&r, "B").position, 1);
+ assert_eq!(place(&r, "C").position, 2);
+ assert_eq!(place(&r, "A").position, 3);
+ assert!(place(&r, "A").disqualified);
+ assert!(!place(&r, "B").disqualified);
+ assert!(!place(&r, "C").disqualified);
+ // The DQ does not erase A's on-track laps in the metric — only the ranking moves.
+ assert_eq!(place(&r, "A").laps, 3);
+ assert!(!r.voided);
+ }
+
+ #[test]
+ fn disqualify_two_leaders_both_sink_below_the_field() {
+ // DQ both A (3 laps) and B (2): C (1 lap) is now first; the two DQ'd competitors
+ // rank behind it, ordered among themselves by their on-track standing then key.
+ let mut events = pass_events("A", &[0, 5_000_000, 10_000_000, 15_000_000]);
+ events.extend(pass_events("B", &[0, 6_000_000, 13_000_000]));
+ events.extend(pass_events("C", &[0, 7_000_000]));
+ events.push(penalty("A", Penalty::Disqualify));
+ events.push(penalty("B", Penalty::Disqualify));
+
+ let r = score_events(
+ &events,
+ WinCondition::Timed {
+ window_micros: 60_000_000,
+ },
+ start(),
+ );
+
+ assert_eq!(place(&r, "C").position, 1);
+ assert!(!place(&r, "C").disqualified);
+ // A (3 laps) still beats B (2 laps) *within* the disqualified group.
+ assert_eq!(place(&r, "A").position, 2);
+ assert_eq!(place(&r, "B").position, 3);
+ assert!(place(&r, "A").disqualified);
+ assert!(place(&r, "B").disqualified);
+ }
+
+ #[test]
+ fn time_added_reorders_a_best_lap_result() {
+ // BestLap: A's best lap is 2.0s, B's is 2.2s → A first. Add 0.5s to A's deciding
+ // time (2.0 → 2.5) and now B (2.2) wins; A drops to 2nd. The on-track metric is
+ // unchanged (still 2.0s for A) — only the ranking reflects the penalty.
+ let mut events = pass_events("A", &[0, 3_000_000, 5_000_000, 9_000_000]);
+ events.extend(pass_events("B", &[0, 2_500_000, 4_700_000]));
+ events.push(penalty("A", Penalty::TimeAdded { micros: 500_000 }));
+
+ let r = score_events(&events, WinCondition::BestLap, start());
+
+ assert_eq!(place(&r, "B").position, 1);
+ assert_eq!(place(&r, "A").position, 2);
+ assert_eq!(
+ place(&r, "A").metric,
+ Metric::BestLapMicros(Some(2_000_000))
+ );
+ }
+
+ #[test]
+ fn time_added_reorders_a_timed_tiebreak() {
+ // Timed, equal lap count (2 each). Without penalty B (last lap 9.0s) beats A
+ // (last lap 9.5s). Add 1.0s to B's deciding time (9.0 → 10.0): A (9.5) now wins.
+ let mut events = pass_events("A", &[0, 5_000_000, 9_500_000]);
+ events.extend(pass_events("B", &[0, 4_000_000, 9_000_000]));
+ events.push(penalty("B", Penalty::TimeAdded { micros: 1_000_000 }));
+
+ let r = score_events(
+ &events,
+ WinCondition::Timed {
+ window_micros: 60_000_000,
+ },
+ start(),
+ );
+
+ assert_eq!(place(&r, "A").laps, 2);
+ assert_eq!(place(&r, "B").laps, 2);
+ assert_eq!(place(&r, "A").position, 1);
+ assert_eq!(place(&r, "B").position, 2);
+ }
+
+ #[test]
+ fn time_added_accumulates_across_penalties() {
+ // Two +1.0s penalties on B stack to +2.0s. BestLap: A 2.0s, B 1.5s. B's deciding
+ // time becomes 1.5 + 2.0 = 3.5s, so A (2.0) wins despite B's faster raw lap.
+ let mut events = pass_events("A", &[0, 2_000_000]);
+ events.extend(pass_events("B", &[0, 1_500_000]));
+ events.push(penalty("B", Penalty::TimeAdded { micros: 1_000_000 }));
+ events.push(penalty("B", Penalty::TimeAdded { micros: 1_000_000 }));
+
+ let r = score_events(&events, WinCondition::BestLap, start());
+ assert_eq!(place(&r, "A").position, 1);
+ assert_eq!(place(&r, "B").position, 2);
+ }
+
+ #[test]
+ fn heat_voided_flags_the_result() {
+ // A clean 2-competitor heat, then a HeatVoided: the result is flagged voided but
+ // its on-track places are still scored (the standing remains visible).
+ let mut events = pass_events("A", &[0, 5_000_000, 10_000_000]);
+ events.extend(pass_events("B", &[0, 6_000_000]));
+ events.push(Event::HeatVoided {
+ heat: HeatId("h".into()),
+ });
+
+ let r = score_events(
+ &events,
+ WinCondition::Timed {
+ window_micros: 60_000_000,
+ },
+ start(),
+ );
+
+ assert!(r.voided);
+ assert_eq!(place(&r, "A").position, 1);
+ assert_eq!(place(&r, "B").position, 2);
+ }
+
+ #[test]
+ fn penalty_and_marshaling_compose() {
+ // A's middle pass is a phantom voided by marshaling (A: 2 laps → 1), and B is
+ // disqualified. Score through the adjudicated wrapper over the *corrected* stream
+ // (here via crate::event::score_marshaled). Expect A first (its sole remaining
+ // lap), B disqualified to last.
+ use crate::event::score_marshaled;
+ use gridfpv_events::LogRef;
+
+ let mut events: Vec = Vec::new();
+ events.push(Event::Pass(pass("A", 0, 0))); // offset 0
+ events.push(Event::Pass(pass("A", 2_000_000, 1))); // offset 1 — phantom
+ events.push(Event::Pass(pass("A", 6_000_000, 2))); // offset 2
+ events.extend(pass_events("B", &[0, 4_000_000, 8_000_000])); // B: 2 laps
+ events.push(Event::DetectionVoided { target: LogRef(1) });
+ events.push(penalty("B", Penalty::Disqualify));
+
+ let r = score_marshaled(
+ &events,
+ WinCondition::Timed {
+ window_micros: 60_000_000,
+ },
+ start(),
+ );
+
+ // Marshaling collapsed A to a single lap (0 → 6.0s).
+ assert_eq!(place(&r, "A").laps, 1);
+ // B disqualified despite 2 on-track laps: ranked last and flagged.
+ assert_eq!(place(&r, "A").position, 1);
+ assert_eq!(place(&r, "B").position, 2);
+ assert!(place(&r, "B").disqualified);
+ }
}
diff --git a/crates/engine/src/single_elim.rs b/crates/engine/src/single_elim.rs
index fc78de4..32c6666 100644
--- a/crates/engine/src/single_elim.rs
+++ b/crates/engine/src/single_elim.rs
@@ -279,8 +279,10 @@ mod tests {
position: *position,
laps: *laps,
metric: Metric::LastLapAt(None),
+ ..Default::default()
})
.collect(),
+ ..Default::default()
}
}
diff --git a/crates/engine/src/timed_qual.rs b/crates/engine/src/timed_qual.rs
index 66fd957..896fa04 100644
--- a/crates/engine/src/timed_qual.rs
+++ b/crates/engine/src/timed_qual.rs
@@ -251,6 +251,7 @@ mod tests {
position,
laps,
metric,
+ ..Default::default()
}
}
@@ -265,6 +266,7 @@ mod tests {
placement(name, (i as u32) + 1, 0, Metric::BestLapMicros(*micros))
})
.collect(),
+ ..Default::default()
}
}
@@ -278,6 +280,7 @@ mod tests {
placement(name, (i as u32) + 1, *laps, Metric::LastLapAt(None))
})
.collect(),
+ ..Default::default()
}
}
@@ -416,6 +419,7 @@ mod tests {
placement("A", 1, 3, Metric::BestConsecutiveMicros(a)),
placement("B", 2, 3, Metric::BestConsecutiveMicros(b)),
],
+ ..Default::default()
};
let r1 = CompletedHeat::new("round-1", round(Some(6_000_000), Some(5_500_000)));
// A improves to 5.0s in round 2; B holds at 5.5s. A's best (5.0) beats B's (5.5).
diff --git a/crates/engine/src/zippyq.rs b/crates/engine/src/zippyq.rs
index dfaf4d0..307a8ef 100644
--- a/crates/engine/src/zippyq.rs
+++ b/crates/engine/src/zippyq.rs
@@ -229,8 +229,10 @@ mod tests {
position: *position,
laps: *laps,
metric: Metric::LastLapAt(None),
+ ..Default::default()
})
.collect(),
+ ..Default::default()
}
}
diff --git a/crates/engine/tests/double_elim_live.rs b/crates/engine/tests/double_elim_live.rs
index 047369c..d353b79 100644
--- a/crates/engine/tests/double_elim_live.rs
+++ b/crates/engine/tests/double_elim_live.rs
@@ -119,12 +119,19 @@ fn run_bracket_heat(plan: &HeatPlan, winner: &CompetitorRef) -> CompletedHeat {
position: next_pos,
laps: 0,
metric: gridfpv_engine::scoring::Metric::LastLapAt(None),
+ ..Default::default()
});
next_pos += 1;
}
}
- CompletedHeat::new(plan.heat.0.clone(), HeatResult { places })
+ CompletedHeat::new(
+ plan.heat.0.clone(),
+ HeatResult {
+ places,
+ ..Default::default()
+ },
+ )
}
/// The seat node index behind a `node-{i}` competitor ref, if it has that shape.
@@ -142,6 +149,7 @@ fn remap(place: &Placement, as_ref: &CompetitorRef) -> Placement {
position: place.position,
laps: place.laps,
metric: place.metric,
+ disqualified: place.disqualified,
}
}
diff --git a/crates/engine/tests/full_event_live.rs b/crates/engine/tests/full_event_live.rs
index 9f3e32f..5b40e19 100644
--- a/crates/engine/tests/full_event_live.rs
+++ b/crates/engine/tests/full_event_live.rs
@@ -69,6 +69,7 @@ fn remap(place: &Placement, as_ref: &CompetitorRef) -> Placement {
position: place.position,
laps: place.laps,
metric: place.metric,
+ disqualified: place.disqualified,
}
}
@@ -143,12 +144,16 @@ fn run_real_heat(plan: &HeatPlan, winner: &CompetitorRef) -> HeatResult {
position: next_pos,
laps: 0,
metric: Metric::LastLapAt(None),
+ ..Default::default()
});
next_pos += 1;
}
}
- HeatResult { places }
+ HeatResult {
+ places,
+ ..Default::default()
+ }
}
/// The intended winner of a heat: the highest seed present in the lineup. Seeds here are
diff --git a/crates/engine/tests/multi_main_live.rs b/crates/engine/tests/multi_main_live.rs
index 5547805..b97fee1 100644
--- a/crates/engine/tests/multi_main_live.rs
+++ b/crates/engine/tests/multi_main_live.rs
@@ -68,6 +68,7 @@ fn remap(place: &Placement, as_ref: &CompetitorRef) -> Placement {
position: place.position,
laps: place.laps,
metric: place.metric,
+ disqualified: place.disqualified,
}
}
@@ -131,12 +132,19 @@ fn run_main_heat(plan: &HeatPlan, winner: &CompetitorRef) -> CompletedHeat {
position: next_pos,
laps: 0,
metric: gridfpv_engine::scoring::Metric::LastLapAt(None),
+ ..Default::default()
});
next_pos += 1;
}
}
- CompletedHeat::new(plan.heat.0.clone(), HeatResult { places })
+ CompletedHeat::new(
+ plan.heat.0.clone(),
+ HeatResult {
+ places,
+ ..Default::default()
+ },
+ )
}
#[test]
diff --git a/crates/engine/tests/single_elim_live.rs b/crates/engine/tests/single_elim_live.rs
index 9c7c3ba..2018047 100644
--- a/crates/engine/tests/single_elim_live.rs
+++ b/crates/engine/tests/single_elim_live.rs
@@ -136,12 +136,19 @@ fn run_bracket_heat(plan: &HeatPlan, winner: &CompetitorRef) -> CompletedHeat {
position: next_pos,
laps: 0,
metric: gridfpv_engine::scoring::Metric::LastLapAt(None),
+ ..Default::default()
});
next_pos += 1;
}
}
- CompletedHeat::new(plan.heat.0.clone(), HeatResult { places })
+ CompletedHeat::new(
+ plan.heat.0.clone(),
+ HeatResult {
+ places,
+ ..Default::default()
+ },
+ )
}
/// The seat node index behind a `node-{i}` competitor ref, if it has that shape.
@@ -159,6 +166,7 @@ fn remap(place: &Placement, as_ref: &CompetitorRef) -> Placement {
position: place.position,
laps: place.laps,
metric: place.metric,
+ disqualified: place.disqualified,
}
}
From f3b2d8161eaa33c3ab3fc017e2413794a35a9daf Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 18:30:04 +0000
Subject: [PATCH 039/362] fix(adapters): rh_live event_kind match covers
CompetitorRegistered (#60)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#60 added Event::CompetitorRegistered but rh_live.rs's exhaustive match wasn't
updated, so the adapters live test failed to compile under --features live. Core
CI excludes the live feature (openssl), so only cargo xtask live caught it. Add
the arm. (CI gap: core CI should compile-check the live tests — noted for the CI
rework at the GitHub migration.)
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
crates/adapters/tests/rh_live.rs | 1 +
1 file changed, 1 insertion(+)
diff --git a/crates/adapters/tests/rh_live.rs b/crates/adapters/tests/rh_live.rs
index e2c8a60..fa380ea 100644
--- a/crates/adapters/tests/rh_live.rs
+++ b/crates/adapters/tests/rh_live.rs
@@ -33,6 +33,7 @@ fn event_kind(e: &Event) -> &'static str {
Event::SessionStarted { .. } => "SessionStarted",
Event::SessionEnded { .. } => "SessionEnded",
Event::CompetitorSeen { .. } => "CompetitorSeen",
+ Event::CompetitorRegistered { .. } => "CompetitorRegistered",
Event::Pass(_) => "Pass",
Event::HeatScheduled { .. } => "HeatScheduled",
Event::HeatStateChanged { .. } => "HeatStateChanged",
From 28c58af3bb54a3b3d9ec23522d0eb88c8d783eb2 Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 18:34:11 +0000
Subject: [PATCH 040/362] fix(rd-console): default Director address to the
served origin, not localhost
The console is served by the Director, so its base URL is the page's own origin.
Defaulting the login field to localhost:8080 meant a remote browser tried to reach
ITS OWN machine -> Failed to fetch + snapshotting/reconnecting bounce. Prefer
location.origin.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
frontend/apps/rd-console/src/screens/Login.svelte | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/frontend/apps/rd-console/src/screens/Login.svelte b/frontend/apps/rd-console/src/screens/Login.svelte
index e914997..4b5e0b6 100644
--- a/frontend/apps/rd-console/src/screens/Login.svelte
+++ b/frontend/apps/rd-console/src/screens/Login.svelte
@@ -10,8 +10,11 @@
let { session }: { session: Session } = $props();
- // Seed the field from any restored base URL once; the input owns it thereafter.
- let baseUrl = $state(untrack(() => session.baseUrl) || 'http://localhost:8080');
+ // The console is served BY the Director, so the Director is this page's own origin —
+ // default to it (prevents the "localhost from the wrong machine" Failed-to-fetch trap).
+ let baseUrl = $state(
+ globalThis.location?.origin || untrack(() => session.baseUrl) || 'http://localhost:8080'
+ );
let token = $state('');
function submit(e: Event) {
From 276d94df75e9d074507aa5ec87733b05affca2a6 Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 18:44:16 +0000
Subject: [PATCH 041/362] docs(roadmap): pivot v0.4 to vertical feature slices
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reframe how v0.4 is delivered — from horizontal layers (engine →
server → console) to vertical feature slices, each built backend →
API → UI until it genuinely works. Root realization: the missing
backbone is the Event aggregate (you are always inside an event;
an event owns timers, pilots, heats, format, results).
- Mark v0.1–v0.3 shipped and the v0.4 protocol-server / Svelte-console
/ contract-suite foundation as built.
- Reframe v0.4 as "Direct a single race (RD console) — vertical
slices" with Slices 0–7 (design system, events & workspace, timer
config, pilot registration, heat building, run a heat, marshaling &
results, multi-heat formats).
- Add a "How we build (v0.4+)" callout: vertical slices over
horizontal layers; design is foundation-first; entities carry
auto-generated unique IDs (names/callsigns are display).
- Note v0.5+ (streaming/broadcast, cloud, …) follow once a single
race and a full multi-heat event are directable from the console.
Prose-only; HTML kept well-formed and in the doc's existing style.
Part of #13 (roadmap pivot to vertical slices).
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SL34wThSSbdutQoiLdJ2ZJ
---
docs/roadmap.html | 87 +++++++++++++++++++++++++++++++++++++++--------
1 file changed, 73 insertions(+), 14 deletions(-)
diff --git a/docs/roadmap.html b/docs/roadmap.html
index da86dd6..8d6bd26 100644
--- a/docs/roadmap.html
+++ b/docs/roadmap.html
@@ -54,13 +54,35 @@
+ How we build (v0.4+): vertical slices over horizontal layers. Through
+ v0.3 we built the spine in horizontal layers — all of the engine, then the protocol
+ server, then the console shell — and that horizontal foundation
+ (engine, server, console, contract-suite/observability) is now done.
+ A hands-on UI review made the lesson plain: stacking more horizontal layers leaves nothing
+ a director can actually use until the very end. So from v0.4 onward we build the
+ workflow through that foundation one feature at a time — each slice goes
+ backend → API → UI and genuinely works end to end before the next
+ begins. The root realization: the missing backbone is the Event
+ aggregate — you are always inside an event, and an event owns its timers, pilots, heats,
+ format, and results.
+
+
+ Two rules ride along. Design is foundation-first: the design system
+ (tokens, components, app shell) is its own first slice, and every later slice inherits it
+ rather than reinventing UI. And entities carry auto-generated unique IDs —
+ names, callsigns, and channels are human-facing display, never the identity.
+
classDef live fill:#eaf7e1,stroke:#43b301,stroke-width:2px;
class v04 live
-
v0.4 (highlighted) is the first build that can run a real event end to end.
+
v0.4 (highlighted) is the first build that can run a real event end to end — and, after a hands-on UI review, the first we build as vertical feature slices rather than horizontal layers (see §1).
- Status (2026-06):v0.1 (walking skeleton) and
- v0.2 (first adapters + replay harness) are complete and
- merged — the SQLite append-only log, projection engine, Rust→TS generation,
- RotorHazard + Velocidrone adapters, recorded-session replay golden tests, and live +
- emulated-signal dockerized-RH tests are all in the tree. As designed, v0.2 already
- ingests a live (dockerized) source — "shadow mode." Next: v0.3 — Race engine.
+ Status (2026-06):v0.1 (walking skeleton),
+ v0.2 (first adapters + replay harness), and v0.3
+ (race engine) are complete and merged — the SQLite append-only log,
+ projection engine, Rust→TS generation, RotorHazard + Velocidrone adapters,
+ recorded-session replay golden tests, live + emulated-signal dockerized-RH tests, the
+ heat-loop state machine, scoring/marshaling, and the six format generators are all in
+ the tree. As designed, v0.2 already ingests a live (dockerized) source — "shadow mode."
+
+
+ The v0.4 foundation is also built: the embedded axum
+ protocol server (snapshot + WS stream, scoping, auth), the
+ Svelte console shell + generated types, and the
+ contract-suite / observability backbone. After a hands-on UI review,
+ the remaining v0.4 work — actually directing a race from the console — is being
+ rebuilt as vertical feature slices (see §1 and the v0.4 section below).
@@ -112,14 +143,42 @@
v0.3 — Race engine
Done when: a full event (qualifying → bracket → winner) runs from a recorded source with correct results and marshaling; generators are table-tested.
-
v0.4 — Director server + RD console
-
Goal: a person can run a real event end to end. (Protocol, Clients)
+
v0.4 — Direct a single race (RD console) — vertical slices
+
Goal: a person can run a real event end to end from the RD console. (Protocol, Clients)
+
+ The horizontal foundation — embedded axum protocol server (snapshot + WS
+ stream, scoping, bearer + LAN join-token auth), the Svelte console shell +
+ shared component library + generated types, and the contract-suite/observability backbone —
+ is built. The remaining work is the director workflow itself, delivered as
+ vertical slices (each backend → API → UI, working before the
+ next):
+
-
Embedded axum protocol server (snapshot + WS stream, scoping, bearer + LAN join-token auth).
-
Svelte frontend foundation + shared component library + generated types; the RD console (setup wizard, registration, live race control, marshaling, results).
-
Tauri shell + single-binary packaging for Windows/Linux/macOS.
+
Slice 0 — Design system foundation. Tokens, component library, and app
+ shell, built first so every later slice inherits a consistent UI.
+
Slice 1 — Events & workspace. The Event aggregate
+ itself: an always-present, non-persistent Practice event; a startup / event-picker
+ flow; you cannot act outside an event; and auth / connect to the served origin.
+
Slice 2 — Timer configuration. Per-event timing source — Sim,
+ RotorHazard URL, or future sources — chosen and validated inside the event.
+
Slice 3 — Pilot registration. Event-scoped pilots with auto-generated
+ IDs plus callsign / channel, and a working Registration UI.
+
Slice 4 — Heat building. Build heats from the registered roster — select
+ pilots, assign channels.
+
Slice 5 — Run a heat (live). Run a heat from the console with a
+ server-authoritative race clock.
+
Slice 6 — Marshaling & results. Void / insert / adjust laps and apply
+ penalties, with adjustments and penalties shown in the results UI.
+
Slice 7 — Multi-heat formats. Select a format and drive its progress —
+ the six generators already exist from v0.3.
-
Done when: a complete event runs live from the RD console against real/dockerized RH, and the single binary launches on all three OSes.
+
Packaging follows alongside: a Tauri shell + single-binary build for Windows/Linux/macOS.
+
Done when: a complete event — registration → heats → live races → marshaling → results, across a multi-heat format — runs live from the RD console against real/dockerized RH, and the single binary launches on all three OSes.
+
+
+ v0.5 and beyond stay as the later milestones — they begin once a single race, and a full
+ multi-heat event, is genuinely directable from the console (v0.4 complete).
+
v0.5 — Racer/spectator PWA
Goal: racers and spectators can follow along on their phones. (Clients)
From 5a16853dcb60effa12c30bea7c617a0291eeb39a Mon Sep 17 00:00:00 2001
From: Ryan Johnson
Date: Sat, 20 Jun 2026 19:06:08 +0000
Subject: [PATCH 042/362] Design system foundation: dark-mode-first tokens,
primitives, restyle (#71)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Slice 0 of the console design system — a modern, cohesive race-operations
visual language in the spirit of Linear / Vercel / live-sports broadcast
graphics. Replaces the "ugly, too simple, not modern" baseline at the
foundation level so every surface inherits it.
Token system (packages/components/src/tokens.css):
- Dark-mode-first, with a light theme via `.theme-light` / `[data-theme]`.
- One source of truth: a neutral slate ramp + electric "grid green" brand
accent + semantic roles (surface/elevated/border/text tiers), the six
heat-phase colors (Scheduled→Staged→Armed→Running→Finished→Scored),
connection states, and success/warn/danger/info.
- Type scale (Inter/system grotesk + tabular mono), spacing, radii, an
elevation/shadow ramp, motion durations/easings, and focus rings.
- Back-compat `--gf-color-*` aliases; honors prefers-reduced-motion.
Base UI primitives (@gridfpv/components/primitives, framework-pure Svelte):
Button (primary/secondary/ghost/danger × sizes, loading), Input, Select,
Field, Card/Panel, Badge, StatusPill (phase + connection, pulses when
live/running), Tabs (ARIA roving tabindex), Banner, Dialog (native