diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aaee08c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +# Core, deterministic checks — the same suite local dev runs, now including the +# frontend + contract suite (which the GitLab pipeline never ran). The heavier +# browser e2e and the dockerized-RotorHazard live suite are gated separately +# (see heavy.yml) per the runner-cost policy. +on: + push: + branches: [main, devel, "milestone/**"] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + rust: + name: Rust — cargo xtask ci + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + # fmt --check && clippy -D warnings && test --all && ts-rs bindings drift-check + - run: cargo xtask ci + + audit: + name: Security — cargo audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + # Cache the cargo-audit binary (and the advisory-db it fetches) so the job + # stays fast — it only reads Cargo.lock, it never builds the workspace. + - uses: Swatinem/rust-cache@v2 + with: + # cargo-audit lives in ~/.cargo/bin and the advisory-db under + # ~/.cargo/advisory-db; key the cache so a fresh install is rare. + cache-bin: true + shared-key: cargo-audit + - name: Install cargo-audit + run: cargo install cargo-audit --locked + # Triaged/accepted advisories are enumerated in ./audit.toml; this fails + # the job on any new advisory (including real vulnerabilities). + - run: cargo audit + + frontend: + name: Frontend — build/check/lint/test/contract + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install deps + run: npm ci + working-directory: frontend + - name: Build + run: npm run build + working-directory: frontend + - name: Type-check + run: npm run check + working-directory: frontend + - name: Lint + run: npm run lint + working-directory: frontend + - name: Unit tests + run: npm test + working-directory: frontend + # The contract suite boots the real Director, so build it first. + - name: Build Director + run: cargo build -p gridfpv-app + - name: Contract suite (strict client↔server wire checks) + run: npm run contract + working-directory: frontend diff --git a/.github/workflows/release-builds.yml b/.github/workflows/release-builds.yml new file mode 100644 index 0000000..88b2be5 --- /dev/null +++ b/.github/workflows/release-builds.yml @@ -0,0 +1,107 @@ +name: Release builds + +# Gated, cost-aware native-app build pipeline — produces the Tauri desktop +# PORTABLE binary (the RELEASE form of GridFPV; see src-tauri/README.md). This is +# a PORTABLE-ONLY build: it builds with `--no-bundle`, so no installers +# (msi/nsis/appimage/deb) are produced — just the self-contained +# gridfpv-desktop[.exe] with the embedded frontend. This is +# DELIBERATELY NOT part of the per-push CI (ci.yml): it does NOT run on devel, +# on pull_request, or on every push, so dev velocity and Actions cost are +# preserved. It only runs: +# - manually (workflow_dispatch — the "Run workflow" button / `gh workflow run`) +# - on merge to main (release builds) +on: + workflow_dispatch: + push: + branches: [main] + +concurrency: + group: release-builds-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: ${{ matrix.os }} — Tauri portable + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + # Single-file portable: the raw self-contained gridfpv-desktop binary + # (frontend embedded via the embed-assets feature — no external dist). + portable-artifact: gridfpv-linux-portable + portable-path: src-tauri/target/release/gridfpv-desktop + - os: windows-latest + portable-artifact: gridfpv-windows-portable + portable-path: src-tauri/target/release/gridfpv-desktop.exe + - os: macos-latest + # Apple Silicon (macos-latest = arm64) — covers every modern Mac; an Intel + # (x86_64) leg is a matrix add if one ever turns up at the field. + portable-artifact: gridfpv-macos-portable + portable-path: src-tauri/target/release/gridfpv-desktop + + steps: + - uses: actions/checkout@v7 + + # Linux-only: Tauri's webkit2gtk/GTK system deps (Windows uses the bundled + # WebView2 runtime and needs none of these). Mirror src-tauri/README.md. + - name: Install Tauri Linux system deps + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + librsvg2-dev \ + libsoup-3.0-dev \ + libjavascriptcoregtk-4.1-dev \ + libssl-dev \ + libayatana-appindicator3-dev + + - uses: dtolnay/rust-toolchain@stable + + # Cache keyed per-OS; src-tauri is a standalone workspace, so point the + # cache at its own lockfile so the matrix legs don't collide. + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + key: ${{ matrix.os }} + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + # Build the frontend (the workspace packages + the rd-console dist that the + # app bundles). This is exactly what tauri.conf.json's beforeBuildCommand + # runs (`cd frontend && npm ci && npm run build`); doing it explicitly here + # makes the dist build a visible, cached step and matches ci.yml's + # conventions (npm ci + npm run build under frontend/). + - name: Build frontend (rd-console dist) + run: | + npm ci + npm run build + working-directory: frontend + + # PORTABLE-ONLY build: `--no-bundle` compiles the gridfpv-desktop crate (with + # the embedded frontend + the Windows icon) into + # src-tauri/target/release/gridfpv-desktop[.exe] but produces NO installers + # (msi/nsis/appimage/deb) — bundle.targets in tauri.conf.json is irrelevant + # because --no-bundle skips bundling entirely. It re-runs beforeBuildCommand + # (idempotent — frontend already built above). + - name: Build Tauri portable binary (--no-bundle) + run: npx -y @tauri-apps/cli@2 build --no-bundle + working-directory: src-tauri + + # Single-file portable: the raw gridfpv-desktop binary. Because gridfpv-app is + # built with the embed-assets feature, the frontend dist is baked into the + # binary, so it self-serves the RD console with no external assets folder — a + # standalone executable. This is the ONLY release artifact (portable-only). + - name: Upload portable binary + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.portable-artifact }} + path: ${{ matrix.portable-path }} + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 3da257a..cf26b66 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,13 @@ # Rust build artifacts /target/ +# Tauri native-app build artifacts. `src-tauri` is the release-only desktop crate +# (excluded from the workspace; built with `cargo tauri build`). Its own `target/` and +# Tauri's generated `gen/` (capability schemas) are derived — never committed. The bundled +# RD-console dist is built fresh by the `beforeBuildCommand`, so it's ignored too. +/src-tauri/target/ +/src-tauri/gen/ + # Claude Code local state (agent worktrees, etc.) .claude/ @@ -12,3 +19,20 @@ # Local reverse-engineering / analysis artifacts (not part of the project) pyghidra_mcp_projects/ + +# Frontend monorepo (frontend/) +frontend/node_modules/ +frontend/**/node_modules/ +frontend/**/dist/ +frontend/**/build/ +frontend/**/.svelte-kit/ +frontend/**/.vite/ +frontend/**/*.tsbuildinfo +frontend/.eslintcache + +# Playwright e2e run artifacts + downloaded browser binaries (never committed) +frontend/test-results/ +frontend/playwright-report/ +frontend/blob-report/ +frontend/.playwright/ +frontend/e2e-artifacts/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4a23760 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,41 @@ +# GridFPV — Project Rules + +Standing rules for working in this repo. These bind every change (human or agent). + +## Display: always use the friendly name, never a raw id + +**Anywhere a value that has a human-friendly name is shown to a user, render the +friendly name — never the raw unique id / ref / uuid / node seat.** + +This applies to *every* surface: tables, lists, dropdowns (the visible label — +the option `value` may stay the raw ref), page headers, section legends, audit / +log lines, graph and chart labels, toasts, tooltips, confirmation dialogs, and +error messages. + +Go through the **shared resolvers** — never re-derive a name inline (that's how +they drift): + +- **Competitor ref → callsign:** `createCompetitorNameResolver` + (`frontend/apps/rd-console/src/lib/competitorName.ts`) +- **Heat id → "‹Round› Heat N" / main tier / custom label:** `heatNameById` + (`frontend/apps/rd-console/src/lib/heats.ts`) +- **Pilot id → callsign**, **round id → round label**, **class id → class name**, + **frequency/channel → band+channel label** (`channels.ts`) — same principle; use + the existing helper, or add one. + +Rules of thumb: + +- Raw ids/refs are **wire handles only**. The UI layer resolves them before display. +- Resolve from a **durable source** (the entity's own record / a projection), not + just live/current state — so **finished, non-current, or not-yet-running** + entities still resolve (e.g. marshaling a *finished* heat must still show + callsigns; a node-seeded heat must resolve `node-0` → the bound pilot). +- A resolver may fall back to the raw ref as a **last resort**, but any **new** + display that shows an entity MUST go through the resolver, not print the id. +- When you add a new entity type that has an id **and** a name, add/extend a + resolver and use it everywhere that entity is shown. + +*Why this is a rule:* friendly-name leaks have been a recurring bug class — +Live control, the global header, marshaling (audit / ruling / protests / add-lap), +graph labels, and heat names have each leaked raw ids at some point. Treat a raw +id reaching the screen as a bug. diff --git a/Cargo.lock b/Cargo.lock index 98ab4a8..493a068 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,61 @@ 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", + "base64 0.22.1", + "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", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.29.0", + "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" @@ -383,11 +438,12 @@ dependencies = [ [[package]] name = "gridfpv-adapters" -version = "0.1.0" +version = "0.4.0-alpha.1" dependencies = [ "gridfpv-events", "gridfpv-projection", "gridfpv-testkit", + "native-tls", "rust_socketio", "serde", "serde_json", @@ -396,29 +452,41 @@ dependencies = [ [[package]] name = "gridfpv-app" -version = "0.1.0" +version = "0.4.0-alpha.1" dependencies = [ + "axum", "gridfpv-adapters", + "gridfpv-engine", "gridfpv-events", "gridfpv-projection", + "gridfpv-server", "gridfpv-storage", + "gridfpv-testkit", + "http-body-util", + "mime_guess", + "rust-embed", "serde_json", + "tokio", + "tower", + "tower-http", ] [[package]] name = "gridfpv-engine" -version = "0.1.0" +version = "0.4.0-alpha.1" dependencies = [ "gridfpv-adapters", "gridfpv-events", "gridfpv-projection", "gridfpv-testkit", + "serde", "serde_json", + "ts-rs", ] [[package]] name = "gridfpv-events" -version = "0.1.0" +version = "0.4.0-alpha.1" dependencies = [ "serde", "serde_json", @@ -427,16 +495,39 @@ dependencies = [ [[package]] name = "gridfpv-projection" -version = "0.1.0" +version = "0.4.0-alpha.1" dependencies = [ "gridfpv-events", "serde", "serde_json", + "ts-rs", +] + +[[package]] +name = "gridfpv-server" +version = "0.4.0-alpha.1" +dependencies = [ + "axum", + "futures-util", + "getrandom 0.3.4", + "gridfpv-adapters", + "gridfpv-engine", + "gridfpv-events", + "gridfpv-projection", + "gridfpv-storage", + "gridfpv-testkit", + "http-body-util", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite 0.24.0", + "tower", + "ts-rs", ] [[package]] name = "gridfpv-storage" -version = "0.1.0" +version = "0.4.0-alpha.1" dependencies = [ "gridfpv-events", "rusqlite", @@ -447,7 +538,7 @@ dependencies = [ [[package]] name = "gridfpv-testkit" -version = "0.1.0" +version = "0.4.0-alpha.1" [[package]] name = "h2" @@ -528,12 +619,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" 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" @@ -548,6 +651,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -790,6 +894,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" @@ -802,6 +912,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.2.1" @@ -867,6 +987,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.117" @@ -875,6 +1004,7 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] @@ -1087,6 +1217,40 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rust_engineio" version = "0.6.0" @@ -1106,7 +1270,7 @@ dependencies = [ "serde_json", "thiserror 1.0.69", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.21.0", "tungstenite 0.21.0", "url", ] @@ -1189,6 +1353,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -1264,6 +1437,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" @@ -1287,12 +1471,33 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "slab" version = "0.4.12" @@ -1473,10 +1678,23 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "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" @@ -1511,6 +1729,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" @@ -1537,6 +1779,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1547,13 +1790,23 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", "url", ] @@ -1575,6 +1828,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-core", ] @@ -1636,6 +1890,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" @@ -1658,6 +1930,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1706,6 +1984,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1948,7 +2236,11 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "xtask" -version = "0.1.0" +version = "0.4.0-alpha.1" +dependencies = [ + "gridfpv-testkit", + "serde_json", +] [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index 3032d1d..ffa6c24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,16 +7,29 @@ members = [ "crates/adapters", "crates/engine", "crates/testkit", + "crates/server", "crates/app", "xtask", ] +# `src-tauri` is the **release-only** native desktop app (Tauri 2). It is deliberately +# EXCLUDED from this workspace so `cargo xtask ci` (which runs `cargo {clippy,test} --all` / +# `--workspace`) never tries to compile it — CI runners have no webkit2gtk/GTK system libs. +# The Tauri build is a separate command (`cd src-tauri && cargo tauri build`); it carries its +# OWN `[workspace]` table so it resolves as a standalone crate that path-depends back into the +# spine (`gridfpv-app`). See `src-tauri/README.md`. The hosted Director (`gridfpv-app`) stays +# the dev path and is unaffected. +exclude = ["src-tauri"] # Shared metadata + dependency versions for every crate in the spine. # Crates inherit these with `field.workspace = true` so versions are set once. [workspace.package] +# The ONE product version (v0.4.0-alpha.1 scheme: 0.x milestones, -alpha/-beta pre-releases; +# bump with `cargo xtask version `). Crates inherit it; tauri.conf.json and the +# console package.json are kept in lock-step by the same xtask command. +version = "0.4.0-alpha.1" edition = "2024" license = "AGPL-3.0-or-later" -repository = "https://gitlab.com/gridfpv/gridfpv" +repository = "https://github.com/GridFPV/gridfpv" rust-version = "1.85" [workspace.dependencies] @@ -26,6 +39,7 @@ gridfpv-storage = { path = "crates/storage" } gridfpv-projection = { path = "crates/projection" } gridfpv-adapters = { path = "crates/adapters" } gridfpv-engine = { path = "crates/engine" } +gridfpv-server = { path = "crates/server" } gridfpv-testkit = { path = "crates/testkit" } # Third-party. diff --git a/audit.toml b/audit.toml new file mode 100644 index 0000000..0747184 --- /dev/null +++ b/audit.toml @@ -0,0 +1,26 @@ +# cargo-audit configuration — see https://github.com/rustsec/rustsec/tree/main/cargo-audit +# +# `cargo audit` fails the build on any advisory it surfaces. We keep the default +# behaviour (informational `unmaintained`/`yanked`/`notice` warnings ARE treated +# as failures) so the CI job is a real gate, and explicitly enumerate the small +# set of advisories we have triaged and chosen to accept below. Anything new — +# including any actual vulnerability — therefore breaks CI until it is handled. + +[advisories] +ignore = [ + # `backoff` is unmaintained (RUSTSEC-2025-0012). It is pulled in ONLY as a + # transitive dependency of `rust_socketio` 0.6.0 (the latest published + # release), behind the optional `live` feature of `gridfpv-adapters` used by + # the dockerized-RotorHazard e2e suite — never in shipped/default builds. + # No patched version exists and 0.6.0 is upstream's newest release, so there + # is no `cargo update` fix available. This is an unmaintained warning, not a + # vulnerability. Revisit when `rust_socketio` cuts a release that drops the + # `backoff` dependency (or when we replace the `live` socket.io client). + "RUSTSEC-2025-0012", + + # `instant` is unmaintained (RUSTSEC-2024-0384). Transitive dependency of + # `backoff` (see above) → `rust_socketio` 0.6.0, same optional `live`-only + # code path. No patched version; resolved automatically once the `backoff` + # chain is dropped upstream. Unmaintained warning, not a vulnerability. + "RUSTSEC-2024-0384", +] diff --git a/bindings/ActiveEvent.ts b/bindings/ActiveEvent.ts new file mode 100644 index 0000000..9ed634f --- /dev/null +++ b/bindings/ActiveEvent.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 { EventMeta } from "./EventMeta"; + +/** + * The wire shape of `GET /active-event` — the **Director's currently-active event**, or + * `null` when none is set (issue #90). + * + * The active event is **Director (server-side) state**: there is exactly one Race Director + * running one event, so the selected event lives on the Director, not in each browser. Every + * client reads this on connect/reload to resume into the workspace (or fall to the picker when + * `null`). The `event` field is the full [`EventMeta`] so a client renders the context header + * without a second round-trip. + */ +export type ActiveEvent = { +/** + * The active event's metadata, or `null` when no event is active (→ the picker). + */ +event: EventMeta | null, }; diff --git a/bindings/AuditEntry.ts b/bindings/AuditEntry.ts new file mode 100644 index 0000000..54f3a67 --- /dev/null +++ b/bindings/AuditEntry.ts @@ -0,0 +1,45 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AuditKind } from "./AuditKind"; +import type { CompetitorRef } from "./CompetitorRef"; +import type { LogRef } from "./LogRef"; + +/** + * A single reverse-chronological marshaling audit entry (#55): *what changed, when, what kind*. + * + * A thin, render-ready fact derived from one logged marshaling event. `at` is the event's + * `recorded_at` (the server wall-clock instant the log received it), so the panel can show + * "when"; `summary` is a short human string ("Lap split", "DQ applied"); `kind` drives the visual + * treatment. There is no actor field by design (see [`AuditKind`]). + * + * `competitor` carries the **structured** competitor ref the action targeted (when it targets one), + * kept *out* of `summary` on purpose: the ref is a source-local handle (a pilot id, a node seat), + * not a friendly name, so the client resolves it to the pilot's **callsign** and composes the final + * line (e.g. prepends "Ace · " to "DQ applied"). A server-baked `summary` cannot be re-resolved, so + * the name lives in this field, not in the string (the Marshaling raw-id bug, #214 follow-up). It is + * `None` for the lap-/heat-addressed actions that name no competitor (void, split, heat-void, …). + */ +export type AuditEntry = { +/** + * What kind of marshaling action this was — derived from the event type. + */ +kind: AuditKind, +/** + * When the log received this fact (microseconds since the Unix epoch), if recorded. + * `None` when the append carried no arrival timestamp (e.g. a replay with none supplied). + */ +at: number | null, +/** + * The global append offset of this fact — a stable identity for the entry (and what a + * later "reverse this" would target). Lets the UI key the list deterministically. + */ +at_ref: LogRef, +/** + * The competitor this action targeted, as a **structured ref** the client resolves to a + * callsign and composes into the displayed line. `None` for actions that name no competitor. + */ +competitor: CompetitorRef | null, +/** + * A short human-readable description of the change — **without** the raw competitor ref (that + * is carried structured in `competitor` so the client can show the resolved callsign instead). + */ +summary: string, }; diff --git a/bindings/AuditKind.ts b/bindings/AuditKind.ts new file mode 100644 index 0000000..43c2a1b --- /dev/null +++ b/bindings/AuditKind.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 kind of a marshaling audit entry — *what sort of action* a logged fact was (#55). + * + * Derived purely from the event **type**, this is the "defensible results" surface: every + * marshaling correction is a recorded, attributable, reversible fact (marshaling.html §3.3). + * The automatic timer pass is included as [`AuditKind::Pass`] so the panel can distinguish a + * *human ruling* from an *automatic detection* — but note `marshaling_log` only folds the + * human rulings into entries (a heat has far too many passes to list); `Pass` exists so a + * future "show automatic detections too" toggle is an additive consumer, not a model change. + * + * There is deliberately **no actor / who**: per the no-login decision every change is implicitly + * the RD, so naming an actor would be a false precision (marshaling.html §3.3, the audit records + * what-changed-when, and the single-RD trust model supplies the who). + */ +export type AuditKind = "Voided" | "Inserted" | "Adjusted" | "Split" | "PenaltyApplied" | "LapThrownOut" | "ProtestFiled" | "ProtestResolved" | "RulingReversed" | "HeatVoided" | "Pass"; 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/ChannelCapability.ts b/bindings/ChannelCapability.ts new file mode 100644 index 0000000..847f885 --- /dev/null +++ b/bindings/ChannelCapability.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. + +/** + * What channels a timer can be tuned to (race redesign Slice 4a) — its *channel capability*, + * declared generically (NOT RotorHazard-specific). + * + * A timer is one of two kinds: + * + * - [`Fixed`](ChannelCapability::Fixed) — the timer supports only a **specific allowed set** of + * built-in catalog frequencies (raw MHz). A limited timer (e.g. a fixed-band module) exposes + * only what it physically supports; a console must pick a heat's channels from this set. + * - [`Flexible`](ChannelCapability::Flexible) — the timer accepts **any** frequency: the whole + * standard catalog *plus* arbitrary custom raw MHz (e.g. RotorHazard, whose nodes tune freely). + * + * Externally tagged so it maps to a TS discriminated union. It is **additive** on the wire and on + * disk: a timer persisted before the channel model existed deserializes with the + * [`Default`] capability ([`Flexible`](ChannelCapability::Flexible)), so old `timers.json` files + * round-trip and stay valid. + */ +export type ChannelCapability = { "Fixed": { +/** + * The allowed built-in channel centre frequencies, in raw MHz, in preference order. + */ +channels: Array, } } | "Flexible"; diff --git a/bindings/ChannelCatalogEntry.ts b/bindings/ChannelCatalogEntry.ts new file mode 100644 index 0000000..916d5ad --- /dev/null +++ b/bindings/ChannelCatalogEntry.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. + +/** + * One entry in the standard FPV channel catalog: a band, its channel label, and the raw centre + * frequency in **MHz** (race redesign Slice 4a). + * + * The `band`/`channel` pair is the human-readable handle a console offers; `mhz` is the + * authoritative value every timer and the engine actually tune/allocate on. Exported via + * `ts_rs::TS` so the frontend can render the catalog directly. + */ +export type ChannelCatalogEntry = { +/** + * The band name (e.g. `"Raceband"`, `"Fatshark"`, `"Boscam A"`, `"DJI"`, `"HDZero"`). + */ +band: string, +/** + * The channel label within the band (e.g. `"R1"`, `"F4"`, `"A8"`). + */ +channel: string, +/** + * The channel's centre frequency in megahertz (the raw value the engine/timer use). + */ +mhz: number, }; diff --git a/bindings/ChannelMode.ts b/bindings/ChannelMode.ts new file mode 100644 index 0000000..e0ef5f8 --- /dev/null +++ b/bindings/ChannelMode.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. + +/** + * How a [`RoundDef`] assigns **video channels** to its heats (race redesign Slice 7a). + * + * Two models, chosen by the round's competition shape: + * + * - [`Static`](Self::Static) — **time-trial / qualifying** (GQ-style): every member has a *fixed* + * channel assigned at membership ([`MemberSlot::channel`], drawn from the event's **primary + * timer**'s `available_channels`). Heats are **channel-balanced** — one pilot per channel per + * heat, ≤ `node_count` pilots per heat — so every member flies across the format's rounds. + * - [`PerHeat`](Self::PerHeat) — **brackets**: the bracket decides matchups, so channels are + * assigned **per heat** from the timer's pool (the existing first-fit allocation), each heat ≤ + * `node_count` pilots. + * + * Defaulted by format on round-create (`timed_qual` / `round_robin` → [`Static`](Self::Static); + * the elimination / multi-main formats → [`PerHeat`](Self::PerHeat)); the default `Default` impl is + * [`PerHeat`](Self::PerHeat) so a round persisted before this field existed reads back with the + * prior per-heat behaviour. Derives serde + `ts_rs::TS`. + */ +export type ChannelMode = "Static" | "PerHeat"; diff --git a/bindings/Class.ts b/bindings/Class.ts new file mode 100644 index 0000000..7e13926 --- /dev/null +++ b/bindings/Class.ts @@ -0,0 +1,61 @@ +// 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 { ClassSource } from "./ClassSource"; + +/** + * One class in the application-level directory (issue #84). + * + * The wire shape `GET /classes` returns and the on-disk shape `classes.json` persists: a stable + * [`ClassId`] (auto-generated, never user-entered), a required `name`, a [`source`](Class::source) + * provenance, and a little optional metadata. The optional fields are omitted from the wire when + * unset (`skip_serializing_if`). Derives serde (its JSON *is* both the wire and the persisted form) + * and `ts_rs::TS` so the frontend reads a generated `Class` type. + */ +export type Class = { +/** + * The stable handle a per-event selection references and the API addresses + * (`PUT /classes/{id}`). The same [`ClassId`] the scope/log layer uses — directory and scope + * never disagree on what a class id is. + */ +id: ClassId, +/** + * The class's **name** — the required display label (e.g. `"Open"`, `"Spec 5\""`). The + * directory's one mandatory field; everything else is optional metadata. + */ +name: string, +/** + * Where this class came from (see [`ClassSource`]). Defaults to + * [`Custom`](ClassSource::Custom). + */ +source: ClassSource, +/** + * An optional **reference** id/handle into the source system (e.g. a MultiGP class id), if + * recorded. Free-form. Omitted from the wire when unset. + */ +reference?: string, +/** + * An optional free-text description / notes for the class. Omitted from the wire when unset. + */ +description?: string, +/** + * Whether this is a **code-defined built-in** class (issue #84) — one of the canonical + * standard FPV classes seeded into every directory with a fixed id, so cross-event / + * cross-Director standings aggregate with zero reconciliation. Built-ins are **read-only**: + * they cannot be edited or deleted, and are **not** persisted to `classes.json` (they are + * re-seeded on every boot). A user-created [`Custom`](ClassSource::Custom) class is never a + * built-in. Defaults to `false` and is omitted from the wire / disk when false, so a custom + * class's JSON is unchanged. + */ +builtin?: boolean, +/** + * Whether this class is **hidden / archived** from the per-event class picker (hide/archive + * classes). A pure **visibility preference**, not an edit: the RD can hide a class they don't + * use (especially a built-in) so it stops cluttering the per-event picker, while it stays in + * the directory and the main Classes view (where it can be un-hidden). Because built-ins are + * re-seeded on every boot, this flag is **not** stored on the class itself — it is derived when + * `GET /classes` is built from a persisted set of hidden ids (see [`HIDDEN_CLASSES_FILE`]), so + * a hidden built-in survives a restart. Defaults to `false` and is omitted from the wire / disk + * when false, so a class's persisted JSON is unchanged (and the hidden state never lands in + * `classes.json`). + */ +hidden?: boolean, }; diff --git a/bindings/ClassId.ts b/bindings/ClassId.ts new file mode 100644 index 0000000..37b647d --- /dev/null +++ b/bindings/ClassId.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. + +/** + * 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. + * + * This is the **canonical** class handle the whole stack shares: the event model tags + * a scheduled heat with the class it belongs to ([`Event::HeatScheduled`]) and the + * wire/scope layer addresses a class by the same type + * ([`ClassId`](../../server/scope/struct.ClassId.html), re-exported from here), so the + * log and the protocol never disagree on what a class id is. A transparent string + * newtype like the other event-model ids. + */ +export type ClassId = string; diff --git a/bindings/ClassMembership.ts b/bindings/ClassMembership.ts new file mode 100644 index 0000000..880cd94 --- /dev/null +++ b/bindings/ClassMembership.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 { ClassId } from "./ClassId"; +import type { MemberSlot } from "./MemberSlot"; + +/** + * One class's **membership** within an event (race redesign Slice 1a): the roster pilots that + * race a single [`ClassId`]. + * + * Carried in [`EventMeta::classes_membership`] as a list, one entry per class with any members. + * Derives serde (its JSON *is* the wire form) and `ts_rs::TS` so the frontend reads a generated + * `ClassMembership` type for the per-class roster picker (the UI lands in Slice 1b). + */ +export type ClassMembership = { +/** + * The class these pilots race — one of the event's selected [`classes`](EventMeta::classes). + */ +class: ClassId, +/** + * The roster pilots racing this class, in selection order, **each with an optional assigned + * channel** (race redesign Slice 7a). Each entry is a directory pilot that is also on the + * event's [`roster`](EventMeta::roster); its [`channel`](MemberSlot::channel) is the raw-MHz + * frequency the pilot flies in a *static*-channel-mode round (a fixed, per-membership channel, + * GQ-style). + * + * **Legacy-compatible:** older events persisted this as a bare `Vec`; the + * [`MemberSlot`] (de)serialises through a serde shim ([`member_slots`]) that accepts either + * shape — a plain pilot-id string (legacy) reads back as a [`MemberSlot`] with no channel — so + * pre-Slice-7a meta still loads and restart round-trips. + */ +pilots: Array, }; diff --git a/bindings/ClassSource.ts b/bindings/ClassSource.ts new file mode 100644 index 0000000..5058cc5 --- /dev/null +++ b/bindings/ClassSource.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. + +/** + * Where a [`Class`] came from (issue #84). + * + * A small closed enum the directory records so the RD can tell a canonical built-in class (e.g. a + * MultiGP spec class) from one they typed in themselves. Externally tagged (the default serde enum + * representation) so it maps to a TS string union (`"MultiGP" | "Five33" | … | "Custom" | + * "Other"`), exactly like [`VtxType`](crate::pilots::VtxType). The org variants name the standard + * FPV racing leagues / spec bodies the built-in classes carry as their provenance (shown as a + * badge). Defaults to [`Custom`](ClassSource::Custom) — a class the RD created by hand; users only + * ever create `Custom` classes. [`Other`](ClassSource::Other) is the catch-all for any provenance + * not enumerated. + */ +export type ClassSource = "MultiGP" | "Five33" | "FreedomSpec" | "StreetLeague" | "UDL" | "Custom" | "Other"; diff --git a/bindings/ClassStanding.ts b/bindings/ClassStanding.ts new file mode 100644 index 0000000..4385bf9 --- /dev/null +++ b/bindings/ClassStanding.ts @@ -0,0 +1,43 @@ +// 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 pilot's **contribution to a class's standings** (race redesign Slice 5/6a) — the + * per-pilot / per-class row aggregated across that class's rounds (the season-join shape). + * + * This is the row the Results UI renders per competitor: their final standing position, the + * total points they accrued across the class's rounds, and the headline lap metrics (best lap, + * total counted laps). It is a pure aggregate of the class's scored rounds, so it replays + * identically off the same log + meta. + */ +export type ClassStanding = { +/** + * The competitor this standing is for (the pilot's source-local handle). + */ +competitor: CompetitorRef, +/** + * 1-based overall standing position; tied competitors share a position with the same + * dense, tie-aware "1, 2, 2, 4" convention as [`RankEntry`]. + */ +position: number, +/** + * Total **points** across the class's rounds — the sum of each round's per-pilot points, + * where a round awards `field_size - round_position + 1` points (a win in an N-pilot round + * is worth N, last is worth 1). The headline metric the standings rank on. + */ +points: number, +/** + * The competitor's **best lap** across every heat of the class's rounds, in microseconds, + * or `None` when they completed no lap. The qualifying-style tie-break / display metric. + */ +best_lap_micros: number | null, +/** + * The **total counted laps** the competitor completed across the class's rounds (each + * round's laps under that round's win condition). A display / secondary metric. + */ +total_laps: number, +/** + * How many of the class's rounds this competitor appeared in (was ranked in) — context for + * the points total (a pilot who skipped a round has fewer rounds to accrue from). + */ +rounds_entered: number, }; diff --git a/bindings/ClassStandings.ts b/bindings/ClassStandings.ts new file mode 100644 index 0000000..0f7251f --- /dev/null +++ b/bindings/ClassStandings.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 { ClassId } from "./ClassId"; +import type { ClassStanding } from "./ClassStanding"; + +/** + * A class's **standings** (race redesign Slice 5/6a): the ordered per-pilot rows aggregated + * across the class's rounds, plus the class id they are for. + * + * The season-join projection the Results screen reads: [`class_standings`] folds the log + meta + * into one [`ClassStanding`] per competitor that raced the class, best standing first. Pure and + * deterministic — the same log + meta always yields the same ordered standings. + */ +export type ClassStandings = { +/** + * The class these standings are for. + */ +class: ClassId, +/** + * The per-pilot standings, best first (ties adjacent, sharing a position). + */ +standings: Array, }; diff --git a/bindings/Command.ts b/bindings/Command.ts new file mode 100644 index 0000000..2572004 --- /dev/null +++ b/bindings/Command.ts @@ -0,0 +1,237 @@ +// 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 { ClassId } from "./ClassId"; +import type { CompetitorRef } from "./CompetitorRef"; +import type { FillMode } from "./FillMode"; +import type { HeatId } from "./HeatId"; +import type { LogRef } from "./LogRef"; +import type { Penalty } from "./Penalty"; +import type { PilotId } from "./PilotId"; +import type { ProtestOutcome } from "./ProtestOutcome"; +import type { RoundId } from "./RoundId"; +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), [`Start`](Command::Start), + * [`Finalize`](Command::Finalize), [`Advance`](Command::Advance), the off-ramps + * [`Revert`](Command::Revert), [`Abort`](Command::Abort), + * [`Restart`](Command::Restart), [`Discard`](Command::Discard), and the runtime-clock + * **overrides** [`SkipCountdown`](Command::SkipCountdown) / [`ForceEnd`](Command::ForceEnd). + * Each requests the matching [`HeatTransition`](gridfpv_events::HeatTransition); the engine + * validates it against the heat's current state (race-engine.html §2). The ordinary + * `Armed → Running` and `Running → Unofficial` transitions are appended by the Director's + * **runtime clock** (heat-lifecycle Slice 2), not by a command — `SkipCountdown`/`ForceEnd` + * are the manual overrides for when the clock must be bypassed. + * - **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 corrections + * ([`VoidDetection`](Command::VoidDetection), [`InsertLap`](Command::InsertLap), + * [`AdjustLap`](Command::AdjustLap), [`SplitLap`](Command::SplitLap), + * [`VoidHeat`](Command::VoidHeat), [`ApplyPenalty`](Command::ApplyPenalty), + * [`ReverseRuling`](Command::ReverseRuling)), each requesting the corresponding + * marshaling event the projection/scorer folds in (never a mutation; architecture.html §3). + */ +export type Command = { "Stage": { +/** + * The heat to transition. + */ +heat: HeatId, } } | { "Start": { +/** + * The heat to transition. + */ +heat: HeatId, } } | { "SkipCountdown": { +/** + * The heat to transition. + */ +heat: HeatId, } } | { "ForceEnd": { +/** + * The heat to transition. + */ +heat: HeatId, } } | { "Finalize": { +/** + * The heat to transition. + */ +heat: HeatId, } } | { "Advance": { +/** + * The heat to transition. + */ +heat: HeatId, } } | { "Revert": { +/** + * 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, } } | { "SetCurrentHeat": { +/** + * The heat to bring into focus — one already scheduled in the event. + */ +heat: HeatId, } } | { "ScheduleHeat": { +/** + * The id the new heat will carry. + */ +heat: HeatId, +/** + * The competitors in the heat, in lineup order. + */ +lineup: Array, +/** + * The class this heat runs in, when the scheduler assigns one. + */ +class?: ClassId, +/** + * The round within the class's schedule, when the scheduler assigns one. + */ +round?: RoundId, +/** + * Per-pilot frequency assignment in raw MHz (e.g. `5800`); empty when none is + * assigned (a sim race, or the free-text path). + */ +frequencies?: Array<[CompetitorRef, number]>, +/** + * An **optional human label** for a manually-built heat. When set it becomes the + * heat's display name everywhere (overriding the derived "‹Round› Heat N" / tier + * convention); `None` (the default / the generator path) keeps the auto-name. + * Threaded straight into the emitted [`Event::HeatScheduled`]. + */ +label?: string, } } | { "FillRound": { +/** + * The round to fill — one of the event's [`rounds`](crate::events::EventMeta::rounds). + */ +round: RoundId, +/** + * How much of the round to fill in this one command (#216): + * + * - [`FillMode::Next`] (the default — wire-compatible with the original `{ round }` + * shape) schedules the **single** next heat the generator emits. + * - [`FillMode::All`] loops the generator — append a heat, re-fold the round's state, + * draw the next — until the round reports **complete** (no more heats producible now), + * filling a whole deterministic round in one round-trip. + * + * Either way an already-complete round (or one whose outstanding heat must be scored + * first) appends nothing and acks a typed ok — `All` is just `Next` iterated to that + * terminal state, so it stays idempotent on re-run. + */ +mode: FillMode, } } | { "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, +/** + * The heat the inserted lap belongs to, so the scorer routes it by tag even when a + * different heat is live (marshaling a finished heat mid-event). `None` only from a + * legacy client — that insertion attributes positionally, the old behavior. + */ +heat?: HeatId, } } | { "AdjustLap": { +/** + * The log offset of the pass to re-time. + */ +target: LogRef, +/** + * The corrected crossing time, on the source clock. + */ +at: SourceTime, } } | { "SplitLap": { +/** + * The log offset of the pass that ends the over-long lap to split. + */ +target: LogRef, +/** + * When the inserted mid-lap crossing happened, 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, } } | { "DeductPoints": { +/** + * The heat the deduction is recorded against. + */ +heat: HeatId, +/** + * The competitor losing points. + */ +competitor: CompetitorRef, +/** + * How many standings points to deduct. + */ +points: number, } } | { "ThrowOutLap": { +/** + * The log offset of the pass that *ends* the lap to throw out. + */ +target: LogRef, } } | { "FileProtest": { +/** + * The heat the protest concerns. + */ +heat: HeatId, +/** + * The competitor the protest is about. + */ +competitor: CompetitorRef, +/** + * A free-text note describing the protest. + */ +note: string, } } | { "ResolveProtest": { +/** + * The log offset of the `ProtestFiled` this resolves. + */ +target: LogRef, +/** + * How the protest was resolved. + */ +outcome: ProtestOutcome, } } | { "ReverseRuling": { +/** + * The log offset of the ruling to reverse. + */ +target: LogRef, } }; 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..5f2f0dd --- /dev/null +++ b/bindings/CompetitorLaps.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 { CompetitorKey } from "./CompetitorKey"; +import type { Lap } from "./Lap"; +import type { VoidedPass } from "./VoidedPass"; + +/** + * 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, +/** + * Gate passes the RD **voided** (`DetectionVoided`, not undone), chronologically. The + * record of removals travels WITH the lap list so every consumer shares it: the console + * renders them struck-through in place, and threshold re-detection must NOT re-propose a + * crossing the RD explicitly removed — the RSSI trace still shows the crossing, so without + * this the tuner kept offering a voided lap back as "a lap to add". Additive: absent on + * the wire when empty, so older payloads round-trip. + */ +voided?: Array, }; diff --git a/bindings/CompetitorTrace.ts b/bindings/CompetitorTrace.ts new file mode 100644 index 0000000..25d883c --- /dev/null +++ b/bindings/CompetitorTrace.ts @@ -0,0 +1,47 @@ +// 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 { SourceTime } from "./SourceTime"; + +/** + * One competitor's reconstructed RSSI trace within a heat: the concatenated samples plus the + * enter/exit thresholds the timer detected against (marshaling.html §3.2). + * + * The `samples` are the per-tick RSSI values in capture order; `from`/`period_micros` carry the + * time base of the **first** chunk so a UI can place each sample on the source clock (chunks are + * captured back-to-back at a fixed cadence, so the first chunk's base plus the running index + * reconstructs every sample's time — see [`signal_trace`] for the contiguity it assumes). + * `enter`/`exit` are the last thresholds seen for this competitor, `None` until one is captured. + */ +export type CompetitorTrace = { +/** + * Which source-local competitor this trace belongs to. + */ +competitor: CompetitorKey, +/** + * The source-clock timestamp of the first captured sample, if any. + */ +from?: SourceTime, +/** + * Microseconds between consecutive samples (the capture cadence of the first chunk). + */ +period_micros: number, +/** + * The concatenated per-tick RSSI samples (filtered ADC counts), oldest first. + */ +samples: Array, +/** + * The **actual** source-clock timestamp (µs) of each sample, when the trace came from a dense + * history. RH's marshal history is non-uniformly spaced (bursts of peak/nadir entries around + * each crossing), so a uniform `from + i·period_micros` grid badly misplaces samples and + * understates the span; a renderer that has these should plot each sample at its real time. + * `None` for the coarse streaming path, where `from`/`period_micros` is exact. + */ +times?: Array, +/** + * The enter detection threshold, where captured. + */ +enter?: number, +/** + * The exit detection threshold, where captured. + */ +exit?: number, }; 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/CreateClassRequest.ts b/bindings/CreateClassRequest.ts new file mode 100644 index 0000000..9b017d0 --- /dev/null +++ b/bindings/CreateClassRequest.ts @@ -0,0 +1,27 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ClassSource } from "./ClassSource"; + +/** + * The body of `POST /classes` — the fields a caller supplies to create a class (issue #84). + * + * The `name` is required; everything else is optional. The **id is auto-generated** server-side (a + * slug of the name + a short random suffix), never user-entered, mirroring `POST /pilots`. + */ +export type CreateClassRequest = { +/** + * The required name for the new class. + */ +name: string, +/** + * The class's provenance, stored on [`Class::source`]. Defaults to + * [`Custom`](ClassSource::Custom). + */ +source: ClassSource, +/** + * Optional reference id, stored on [`Class::reference`]. + */ +reference?: string, +/** + * Optional description, stored on [`Class::description`]. + */ +description?: string, }; diff --git a/bindings/CreateEventRequest.ts b/bindings/CreateEventRequest.ts new file mode 100644 index 0000000..2826aea --- /dev/null +++ b/bindings/CreateEventRequest.ts @@ -0,0 +1,32 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The body of `POST /events` — the only thing a caller supplies when creating an event. + * + * Just a display `name`; the **id is always auto-generated** (a slug of the name plus a + * short random suffix), never user-entered, per the maintainer's rule. Keeping the id off + * the wire means two events can share a name without colliding and a client can't squat a + * reserved id (e.g. `practice`). + */ +export type CreateEventRequest = { +/** + * The display name for the new event. + */ +name: string, +/** + * Optional **display date** stored on the new event's [`EventMeta::date`] (see there). + * Omitted from the wire when unset — a name-only create stays a one-field body. + */ +date?: string, +/** + * Optional venue / location, stored on [`EventMeta::location`]. + */ +location?: string, +/** + * Optional free-text description, stored on [`EventMeta::description`]. + */ +description?: string, +/** + * Optional organizer name, stored on [`EventMeta::organizer`]. + */ +organizer?: string, }; diff --git a/bindings/CreatePilotRequest.ts b/bindings/CreatePilotRequest.ts new file mode 100644 index 0000000..efe3f37 --- /dev/null +++ b/bindings/CreatePilotRequest.ts @@ -0,0 +1,48 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { VtxType } from "./VtxType"; + +/** + * The body of `POST /pilots` — the fields a caller supplies to create a pilot (issue #74). + * + * The `callsign` is required; everything else is optional. The **id is auto-generated** + * server-side (a slug of the callsign + a short random suffix), never user-entered, mirroring + * `POST /timers` / `POST /events`. + */ +export type CreatePilotRequest = { +/** + * The required callsign for the new pilot. + */ +callsign: string, +/** + * Optional real name, stored on [`Pilot::name`]. + */ +name?: string, +/** + * Optional pronunciation hint, stored on [`Pilot::phonetic`]. + */ +phonetic?: string, +/** + * Optional team / club, stored on [`Pilot::team`]. + */ +team?: string, +/** + * Optional hex color `#RRGGBB`, stored on [`Pilot::color`] (validated). + */ +color?: string, +/** + * Optional ISO 3166-1 alpha-2 country code, stored on [`Pilot::country`] (validated). + */ +country?: string, +/** + * The pilot's video-transmitter system(s), stored (deduped, stable order) on + * [`Pilot::vtx_types`]. Defaults empty (unspecified). + */ +vtx_types: Array, +/** + * Optional MultiGP id, stored on [`Pilot::multigp_id`]. + */ +multigp_id?: string, +/** + * Optional Velocidrone id, stored on [`Pilot::velocidrone_id`]. + */ +velocidrone_id?: string, }; diff --git a/bindings/CreateTimerRequest.ts b/bindings/CreateTimerRequest.ts new file mode 100644 index 0000000..36c1791 --- /dev/null +++ b/bindings/CreateTimerRequest.ts @@ -0,0 +1,34 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChannelCapability } from "./ChannelCapability"; +import type { TimerKind } from "./TimerKind"; + +/** + * The body of `POST /timers` — the config a caller supplies to create a timer (issue #73). + * + * A display `name` plus the [`TimerKind`]; the **id is auto-generated** server-side (a slug of + * the name + a short random suffix), never user-entered, mirroring `POST /events`. + */ +export type CreateTimerRequest = { +/** + * The display name for the new timer. + */ +name: string, +/** + * The kind + config of the new timer. + */ +kind: TimerKind, +/** + * The new timer's **channel capability** (race redesign Slice 4a). Optional and additive — + * omit it for the permissive [`Flexible`](ChannelCapability::Flexible) default. + */ +channel_capability?: ChannelCapability, +/** + * The new timer's **node/slot count** (race redesign Slice 4a) — the heat-size cap. Optional; + * defaults to [`DEFAULT_NODE_COUNT`]. + */ +node_count?: number, +/** + * The new timer's **available channels** in raw MHz (race redesign Slice 4a). Optional; + * defaults to empty (none configured). + */ +available_channels?: Array, }; diff --git a/bindings/Cursor.ts b/bindings/Cursor.ts new file mode 100644 index 0000000..a066c26 --- /dev/null +++ b/bindings/Cursor.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. + +/** + * 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 = "f64")]` so it renders as a plain TS + * `number`. Our cursors/sequences are bounded well below 2^53, so `number` is exact + * and avoids the wire/type mismatch a `u64` → wide-integer TS mapping would introduce. + */ +export type Cursor = number; 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/Event.ts b/bindings/Event.ts index c3b206e..79839c8 100644 --- a/bindings/Event.ts +++ b/bindings/Event.ts @@ -1,12 +1,19 @@ // 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 { ClassId } from "./ClassId"; import type { CompetitorRef } from "./CompetitorRef"; import type { HeatId } from "./HeatId"; import type { HeatTransition } from "./HeatTransition"; import type { LogRef } from "./LogRef"; import type { Pass } from "./Pass"; import type { Penalty } from "./Penalty"; +import type { PilotId } from "./PilotId"; +import type { ProtestOutcome } from "./ProtestOutcome"; +import type { RoundId } from "./RoundId"; import type { SessionId } from "./SessionId"; +import type { SignalChunk } from "./SignalChunk"; +import type { SignalHistory } from "./SignalHistory"; +import type { SignalThresholds } from "./SignalThresholds"; import type { SourceTime } from "./SourceTime"; /** @@ -21,4 +28,100 @@ import type { SourceTime } from "./SourceTime"; * `{ "VariantName": { ..fields } }`, which maps cleanly to a discriminated union in * the generated TypeScript (#4). */ -export type Event = { "AdapterConnected": { adapter: AdapterId, } } | { "AdapterDisconnected": { adapter: AdapterId, } } | { "SessionStarted": { adapter: AdapterId, session: SessionId, } } | { "SessionEnded": { adapter: AdapterId, session: SessionId, } } | { "CompetitorSeen": { adapter: AdapterId, competitor: CompetitorRef, } } | { "Pass": Pass } | { "HeatScheduled": { heat: HeatId, lineup: Array, } } | { "HeatStateChanged": { heat: HeatId, transition: HeatTransition, } } | { "DetectionVoided": { target: LogRef, } } | { "LapInserted": { adapter: AdapterId, competitor: CompetitorRef, at: SourceTime, } } | { "LapAdjusted": { target: LogRef, at: SourceTime, } } | { "HeatVoided": { heat: HeatId, } } | { "PenaltyApplied": { heat: HeatId, competitor: CompetitorRef, penalty: Penalty, } }; +export type Event = { "AdapterConnected": { adapter: AdapterId, } } | { "AdapterDisconnected": { adapter: AdapterId, } } | { "SessionStarted": { adapter: AdapterId, session: SessionId, } } | { "SessionEnded": { adapter: AdapterId, session: SessionId, } } | { "CompetitorSeen": { adapter: AdapterId, competitor: CompetitorRef, } } | { "CompetitorRegistered": { adapter: AdapterId, competitor: CompetitorRef, pilot: PilotId, } } | { "Pass": Pass } | { "SignalChunk": SignalChunk } | { "SignalThresholds": SignalThresholds } | { "SignalHistory": SignalHistory } | { "RoundFieldDrawn": { +/** + * The round whose field this freezes. + */ +round: RoundId, +/** + * The resolved field, in seed order — the draw every later read replays. + */ +field: Array, } } | { "HeatScheduled": { heat: HeatId, lineup: Array, +/** + * The class this heat runs in, where the scheduler tagged it. + */ +class?: ClassId, +/** + * The round within the class's schedule, where the scheduler tagged it. + */ +round?: RoundId, +/** + * Per-pilot frequency assignment in raw MHz (e.g. `5800`). Empty when none is + * assigned (a simulator, or the free-text path that does not assign channels). + */ +frequencies?: Array<[CompetitorRef, number]>, +/** + * An **optional human label** the RD typed when building this heat by hand. When + * present it is the heat's display name everywhere (overriding the derived + * "‹Round› Heat N" / tier convention); `None` for a generator-filled heat, which + * keeps the auto-name. Additive and default-absent, so a pre-existing log (or a + * generator heat) reads back as `None` and round-trips unchanged. + */ +label?: string, } } | { "HeatStateChanged": { heat: HeatId, transition: HeatTransition, } } | { "CurrentHeatSelected": { heat: HeatId, } } | { "HeatStarting": { +/** + * The heat whose start procedure fired (it is in `Armed`). + */ +heat: HeatId, +/** + * The chosen randomized start delay, in **milliseconds**, from this event to the + * `Armed → Running` transition. Written once by the runtime; deterministic on replay. + */ +delay_ms: number, } } | { "HeatFinalizing": { +/** + * The heat whose protest window is open (it is in `Unofficial`). + */ +heat: HeatId, +/** + * The **auto-official deadline**: the server wall-clock instant (microseconds since the + * Unix epoch) at which the runtime appends the auto `Finalize`. The countdown the console + * shows is `at − now`. + */ +at: number, } } | { "DetectionVoided": { target: LogRef, } } | { "LapInserted": { adapter: AdapterId, competitor: CompetitorRef, at: SourceTime, +/** + * The heat the inserted lap belongs to. Unlike a raw [`Pass`] (an untagged wire + * observation attributed positionally), an insertion is an RD statement **about a + * specific heat** — often a finished one being marshaled while a later heat runs — + * so it carries the tag and the heat window routes it by tag, never by position. + * `None` only on legacy logs / commands from before the tag existed (positional + * attribution, the old behavior). Additive: serde defaults it, `#[ts(optional)]` + * keeps the generated TS field `heat?:`. + */ +heat?: HeatId, } } | { "LapAdjusted": { target: LogRef, at: SourceTime, } } | { "LapSplit": { +/** + * The log offset of the pass that *ends* the over-long lap being split. + */ +target: LogRef, +/** + * When the inserted mid-lap crossing happened, on the source clock — between the + * `target` lap's start and `target` itself. + */ +at: SourceTime, } } | { "LapThrownOut": { +/** + * The log offset of the pass that *ends* the lap to exclude from the scored count. + */ +target: LogRef, } } | { "HeatVoided": { heat: HeatId, } } | { "PenaltyApplied": { heat: HeatId, competitor: CompetitorRef, penalty: Penalty, } } | { "ProtestFiled": { +/** + * The heat the protest concerns. + */ +heat: HeatId, +/** + * The competitor the protest is about (whose result is contested). + */ +competitor: CompetitorRef, +/** + * A free-text note describing the protest. + */ +note: string, } } | { "ProtestResolved": { +/** + * The log offset of the [`ProtestFiled`](Event::ProtestFiled) this resolves. + */ +target: LogRef, +/** + * How the protest was resolved. + */ +outcome: ProtestOutcome, } } | { "RulingReversed": { +/** + * The log offset of the ruling to reverse (a penalty, throw-out, protest resolution, or + * heat-void). + */ +target: LogRef, } }; diff --git a/bindings/EventAuditEntry.ts b/bindings/EventAuditEntry.ts new file mode 100644 index 0000000..be4bafc --- /dev/null +++ b/bindings/EventAuditEntry.ts @@ -0,0 +1,46 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AuditKind } from "./AuditKind"; +import type { CompetitorRef } from "./CompetitorRef"; +import type { HeatId } from "./HeatId"; +import type { LogRef } from "./LogRef"; + +/** + * One row of the **event-wide** audit trail (`GET /events/{event_id}/audit`): a per-heat + * marshaling [`AuditEntry`] plus the heat it belongs to. + * + * The per-heat [`AuditEntry`] deliberately carries no heat id — it is served from a heat-scoped + * route where the heat is implicit. The event-wide read merges every heat's trail into one list, + * so each entry must say *which* heat it rules on; the console's Audit page renders (and filters + * by) that tag. The entry's own fields are flattened onto the row, so on the wire this is "an + * `AuditEntry` plus `heat`" — additive, no re-modelling. + */ +export type EventAuditEntry = { +/** + * The heat this ruling belongs to — attributed by the same heat-window rules Marshaling's + * per-heat audit uses ([`heat_window_offsets`]), so the two views can never disagree. + */ +heat: HeatId, +/** + * What kind of marshaling action this was — derived from the event type. + */ +kind: AuditKind, +/** + * When the log received this fact (microseconds since the Unix epoch), if recorded. + * `None` when the append carried no arrival timestamp (e.g. a replay with none supplied). + */ +at: number | null, +/** + * The global append offset of this fact — a stable identity for the entry (and what a + * later "reverse this" would target). Lets the UI key the list deterministically. + */ +at_ref: LogRef, +/** + * The competitor this action targeted, as a **structured ref** the client resolves to a + * callsign and composes into the displayed line. `None` for actions that name no competitor. + */ +competitor: CompetitorRef | null, +/** + * A short human-readable description of the change — **without** the raw competitor ref (that + * is carried structured in `competitor` so the client can show the resolved callsign instead). + */ +summary: string, }; 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/EventMeta.ts b/bindings/EventMeta.ts new file mode 100644 index 0000000..89cf574 --- /dev/null +++ b/bindings/EventMeta.ts @@ -0,0 +1,132 @@ +// 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 { ClassMembership } from "./ClassMembership"; +import type { EventId } from "./EventId"; +import type { PilotId } from "./PilotId"; +import type { RoundDef } from "./RoundDef"; +import type { TimerId } from "./TimerId"; + +/** + * The metadata describing one event in the registry (issue #72). + * + * The wire shape `GET /events` returns: a stable [`EventId`], a human display `name`, the + * creation time, and whether the event is **persistent** (file-backed) or ephemeral (the + * in-memory Practice event). Derives serde (its JSON *is* the wire form) and `ts_rs::TS` + * so the frontend reads a generated `EventMeta` type. + */ +export type EventMeta = { +/** + * The stable handle every per-event route is rooted under (`/events/{id}/…`). + */ +id: EventId, +/** + * The human-readable display name (names are display-only; the id is authoritative). + */ +name: string, +/** + * Creation time in **milliseconds since the Unix epoch** (a plain JSON number — bounded + * far below 2^53, rendered as a TS `number` not a `bigint`, matching every other integer + * on the wire). Practice is seeded at registry construction. + */ +created_at: number, +/** + * Whether the event's log is durable (a SQLite file) or ephemeral (the in-memory + * Practice log, `false`). + */ +persistent: boolean, +/** + * Optional **display date** of the event, as a free-form string (e.g. `"2026-06-20"` or + * `"Sat 20 Jun"`). A string, not an epoch — it is a *human label the RD types*, shown + * verbatim on the picker and (later) the context header; the authoritative machine + * timestamp is [`created_at`](Self::created_at). Omitted from the wire when unset. + */ +date?: string, +/** + * Optional venue / location label (e.g. `"Main field"`). Omitted from the wire when unset. + */ +location?: string, +/** + * Optional free-text description / notes for the event. Omitted from the wire when unset. + */ +description?: string, +/** + * Optional organizer name (the running club / person). Omitted from the wire when unset. + */ +organizer?: string, +/** + * The application-level timers this event **selects** (issue #73) — the per-event reference + * into the app-level [`TimerRegistry`](crate::timers::TimerRegistry). Additive + * (`#[serde(default)]`) so an event persisted before #73 reads back with an empty list; new + * events and Practice default to `["mock"]` (the built-in Mock) so they work out of the + * box. The per-event source bridge runs the selected Sim timers; a selected RotorHazard is a + * reserved no-op stub (2b / #65). + */ +timers: Array, +/** + * The **primary** timer among the selection (issue #112) — redundant timers at one gate, one + * designated **primary** and the rest **alternates**. The per-event source bridge feeds **only + * the active source's** passes into the log (the primary while it's healthy; on a primary drop + * it fails over to the first healthy alternate; on primary recovery it switches back), so two + * timers at the same gate give redundancy without double-counting the same crossing. + * + * Additive (`#[serde(default)]`) so an event persisted before #112 reads back with `None`. + * When unset, the **first** selected timer is the effective primary (see + * [`EventMeta::effective_primary`]). Must name a timer that is in [`timers`](Self::timers); a + * primary not in the selection is ignored (the first selected timer is used instead). + */ +primary_timer?: TimerId, +/** + * The event's **roster** (issue #74) — the application-level [`Pilot`](crate::pilots::Pilot)s + * that race this event, by their [`PilotId`]. The per-event reference into the app-level + * [`PilotDirectory`](crate::pilots::PilotDirectory): a Director maintains their pilots once, + * and each event simply picks which of them race it (mirroring [`timers`](Self::timers)). + * + * Additive (`#[serde(default)]`) so an event persisted before #74 reads back with an empty + * roster; new events and Practice default to an **empty** roster. Channels (which frequency a + * roster pilot flies in a heat) are a separate concern (#117) and are not modelled here. + */ +roster: Array, +/** + * The application-level **classes** this event runs (issue #84) — the per-event reference into + * the app-level [`ClassDirectory`](crate::classes::ClassDirectory), by their [`ClassId`]. The + * per-event selection into the app-level class directory: a Director maintains their racing + * categories once, and each event simply picks which of them run at it (mirroring + * [`roster`](Self::roster) and [`timers`](Self::timers)). + * + * Additive (`#[serde(default)]`) so an event persisted before #84 reads back with an empty + * selection; new events and Practice default to an **empty** selection. This is the registry + * slice only — the rounds / phase engine a class later drives is a separate concern. + */ +classes: Array, +/** + * **Per-class membership** (race redesign Slice 1a) — which roster pilots race each + * [`class`](Self::classes). Each [`ClassMembership`] pairs one selected class with the + * [`PilotId`]s racing it; a roster pilot may be a member of several classes (or none). + * + * Distinct from the [`roster`](Self::roster) (who is *present at the event*) and from the + * [`classes`](Self::classes) selection (which categories *run at all*): membership is the + * finer join of the two — given the present pilots and the running classes, *who races + * which class*. Set per class through + * [`set_class_membership`](EventRegistry::set_class_membership). + * + * Additive (`#[serde(default)]`, omitted from the wire when empty) so an event persisted + * before Slice 1a reads back with no membership; new events and Practice default to an + * **empty** list. The whole field round-trips through the event's persisted meta (issue + * #115), so it is restart-safe for free. + */ +classes_membership?: Array, +/** + * The event's **rounds** (race redesign Slice 2a) — the event-level, class-tagged, *dynamic* + * format-instances this event runs. A [`RoundDef`] scopes a format (a + * [`FormatRegistry`](gridfpv_engine::format::FormatRegistry) name) and its config to one or + * more eligible [`classes`](Self::classes), with a [`SeedingRule`] for how the field is drawn. + * Practice / qualifying rounds are added **as-you-go** through + * [`add_round`](EventRegistry::add_round); brackets (later slices) seed from a prior round's + * ranking via [`SeedingRule::FromRanking`]. + * + * Additive (`#[serde(default)]`, omitted from the wire when empty) so an event persisted before + * Slice 2a reads back with no rounds; new events and Practice default to an **empty** list. The + * whole field round-trips through the event's persisted meta (issue #115), so it is restart-safe + * for free. + */ +rounds?: Array, }; 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/FillMode.ts b/bindings/FillMode.ts new file mode 100644 index 0000000..cd5d479 --- /dev/null +++ b/bindings/FillMode.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. + +/** + * How much of a round a [`Command::FillRound`] fills (#216). Externally tagged like the rest + * of the control vocabulary, so it maps to a TS string-union (`"Next" | "All"`). + */ +export type FillMode = "Next" | "All"; diff --git a/bindings/FormatParam.ts b/bindings/FormatParam.ts new file mode 100644 index 0000000..61f3494 --- /dev/null +++ b/bindings/FormatParam.ts @@ -0,0 +1,34 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ParamKind } from "./ParamKind"; + +/** + * One **parameter schema** entry for a format (race redesign Slice 7a) — the declared shape of a + * config knob the format's generator reads. + * + * The Rounds UI's params editor reads these to render the right control per knob (a number field, + * an enum dropdown, a toggle) with its default; the server declares one per param each generator + * actually consumes. Derives serde (its JSON *is* the `GET /formats` wire shape) + `ts_rs::TS`. + */ +export type FormatParam = { +/** + * The param key as it appears in a round's `params` map (e.g. `"rounds"`). + */ +key: string, +/** + * A human-readable label for the param (e.g. `"Rounds"`). + */ +label: string, +/** + * How the param is rendered / validated. + */ +kind: ParamKind, +/** + * For an [`Enum`](ParamKind::Enum) param, the allowed values (the raw strings stored in + * `params`); empty for `number` / `bool`. + */ +options?: Array, +/** + * The default value (the raw string the format falls back to when the param is unset), or + * `None` when the format has no default. + */ +default?: string, }; diff --git a/bindings/FormatSchema.ts b/bindings/FormatSchema.ts new file mode 100644 index 0000000..b8b2068 --- /dev/null +++ b/bindings/FormatSchema.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 { FormatParam } from "./FormatParam"; + +/** + * A format's **declared param schema** (race redesign Slice 7a): the format name plus the params + * its generator reads. The shape `GET /formats` returns so the Rounds UI renders a params editor. + */ +export type FormatSchema = { +/** + * The format name (a [`FormatRegistry::standard`] name). + */ +name: string, +/** + * The params this format's generator reads, in display order. + */ +params: Array, }; diff --git a/bindings/GraceWindow.ts b/bindings/GraceWindow.ts new file mode 100644 index 0000000..3f7e2b3 --- /dev/null +++ b/bindings/GraceWindow.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. + +/** + * The grace window for late crossings after a heat is finished (race-engine.html + * §2): "late crossings still count until the heat is finalized; the window is + * configurable, default until finalized". + * + * Derives serde + `ts_rs::TS` so it can be carried as a per-round config + * ([`RoundDef::grace_window`](../../server/events/struct.RoundDef.html)) and read by the + * frontend. The runtime completion clock (heat-lifecycle Slice 2) reads this to decide how long + * to hold the heat in `Running` for trailing pilots after the win condition is met, before + * appending the auto `Running → Unofficial`. + */ +export type GraceWindow = "UntilScored" | { "Duration": { +/** + * Length of the grace window, in microseconds on the source clock. + */ +micros: number, } }; diff --git a/bindings/HeatPhase.ts b/bindings/HeatPhase.ts new file mode 100644 index 0000000..d816b32 --- /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 → Unofficial → Final`). + * + * 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 (revert/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" | "Unofficial" | "Final"; diff --git a/bindings/HeatResult.ts b/bindings/HeatResult.ts new file mode 100644 index 0000000..02b99d1 --- /dev/null +++ b/bindings/HeatResult.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 { 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, +/** + * 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/HeatSummary.ts b/bindings/HeatSummary.ts new file mode 100644 index 0000000..6f59acd --- /dev/null +++ b/bindings/HeatSummary.ts @@ -0,0 +1,58 @@ +// 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 { CompetitorRef } from "./CompetitorRef"; +import type { HeatId } from "./HeatId"; +import type { HeatPhase } from "./HeatPhase"; +import type { RoundId } from "./RoundId"; + +/** + * A scheduled heat as the **Heats UI** lists it (race redesign Slice 3b) — the per-heat view + * model the Rounds & Heats stage renders under each round. + * + * Unlike [`LiveRaceState`] (which is *only* about the current heat), this is one entry per heat + * the log ever scheduled, carrying the round/class tag the scheduler assigned so the UI can group + * heats by round and resolve their lineup to pilots. Folded from the log by + * [`heat_summaries`] — pure and recomputable like every other projection. + */ +export type HeatSummary = { +/** + * The heat's id (its scheduled handle, the same one the live/control path drives). + */ +heat: HeatId, +/** + * The heat's lineup — the competitors from its most recent `HeatScheduled`, in lineup order. + */ +lineup: Array, +/** + * The class this heat was tagged with, when the scheduler assigned one (`None` for the + * free-text / untagged path). + */ +class?: ClassId, +/** + * The round this heat was tagged with, when the scheduler assigned one. The Heats UI groups + * the list by this. + */ +round?: RoundId, +/** + * The per-pilot **frequency assignment** of this heat, in raw MHz, paired with the competitor + * it is assigned to — taken from the most recent `HeatScheduled` (race redesign Slice 4b). The + * Heats/Live UI resolves each raw MHz back to a band+channel label via the channel catalog. + * Empty when none was assigned (a sim heat, or the free-text path), in which case the UI shows + * "—". Additive — defaults empty so older logs round-trip. + */ +frequencies?: Array<[CompetitorRef, number]>, +/** + * The **optional human label** the RD typed when building this heat by hand, from the most + * recent `HeatScheduled`. When present the Heats/Live UI shows it as the heat's display name + * (overriding the derived "‹Round› Heat N" / tier convention); `None` for a generator-filled + * heat, which keeps the auto-name. Additive — defaults absent so older logs round-trip. + */ +label?: string, +/** + * The heat's folded loop phase (its derived status: scheduled / running / final / …). + */ +phase: HeatPhase, +/** + * Whether this heat is the one currently on the timer (the live `current_heat`). + */ +is_current: boolean, }; diff --git a/bindings/HeatTransition.ts b/bindings/HeatTransition.ts index 1f4027d..bea457e 100644 --- a/bindings/HeatTransition.ts +++ b/bindings/HeatTransition.ts @@ -3,9 +3,10 @@ /** * A transition of the heat-loop state machine (race-engine.html §2). The recorded * transition is named for the state it enters on the forward path (Staged → Armed → - * Running → Finished → Scored), with the off-ramps (abort/restart/discard) named for - * the action so they stay distinct even when they land on the same state. The engine - * validates legality against the current state. Heat *creation* is a separate event - * ([`Event::HeatScheduled`]) — it carries the lineup, which a transition does not. + * Running → Finished → Finalized), with the off-ramps (revert/abort/restart/discard) + * named for the action so they stay distinct even when they land on the same state. + * The engine validates legality against the current state. Heat *creation* is a + * separate event ([`Event::HeatScheduled`]) — it carries the lineup, which a + * transition does not. */ -export type HeatTransition = "Staged" | "Armed" | "Running" | "Finished" | "Scored" | "Advanced" | "Aborted" | "Restarted" | "Discarded"; +export type HeatTransition = "Staged" | "Armed" | "Running" | "Finished" | "Finalized" | "Advanced" | "Reverted" | "Aborted" | "Restarted" | "Discarded"; 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/JoinTokenResponse.ts b/bindings/JoinTokenResponse.ts new file mode 100644 index 0000000..8f3b875 --- /dev/null +++ b/bindings/JoinTokenResponse.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. + +/** + * The body of a successful `POST /auth/join-token` (protocol.html §5, §9.4) — issue #63. + * + * A freshly-minted **read-only** join token, returned to an authenticated RD so it can + * hand it (e.g. as a venue QR / share URL) to a spectator. The token authenticates LAN + * **reads** but is rejected on control ([`Role::can_control`]). The single-field wire + * shape leaves room to grow additively (an expiry, a scope) without breaking an older + * client that reads only `token`. + */ +export type JoinTokenResponse = { +/** + * The opaque, URL/QR-safe read-only join token (see [`random_token`]). + */ +token: string, }; diff --git a/bindings/Lap.ts b/bindings/Lap.ts new file mode 100644 index 0000000..e0ed895 --- /dev/null +++ b/bindings/Lap.ts @@ -0,0 +1,44 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LogRef } from "./LogRef"; +import type { SourceTime } from "./SourceTime"; + +/** + * A single completed lap: the interval between two consecutive lap-gate passes. + * + * The lap also carries the **global append offsets** ([`LogRef`]) of the two passes that + * bound it — `start_ref` (the opening pass) and `end_ref` (the closing pass). These are the + * *stable* log offsets a marshaling command targets (`VoidDetection`/`AdjustLap`/`SplitLap` + * all key on a single pass's offset), so a UI that selects a lap can address the correct pass + * without the operator hand-typing an offset (#55). They are real global offsets even when the + * lap list is folded from a heat window — the fold is fed `(global_offset, &Event)` pairs, so + * `end_ref`/`start_ref` are valid command targets across a multi-heat log (the heat-window + * re-enumeration bug this fixes). + */ +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. + * Renders as a plain TS `number` (bounded far below 2^53). + */ +duration_micros: number, +/** + * Source-clock timestamp (µs) of the pass that **closes** this lap — the gate-pass instant. + * On the same clock as the signal trace's sample times (`from + i·period_micros`), so the + * Marshaling RSSI graph can place a vertical lap marker at exactly this lap's gate pass + * without re-deriving it from durations (Slice 4 — signal-as-evidence). + */ +at: SourceTime, +/** + * Global append offset of the pass that **opens** this lap (the lap's start gate). + */ +start_ref: LogRef, +/** + * Global append offset of the pass that **closes** this lap (the lap's end gate). This is + * the natural correction target: voiding/adjusting it edits this lap's boundary, and a + * `SplitLap` splits the over-long lap *ending* here. + */ +end_ref: LogRef, }; 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/LifecycleState.ts b/bindings/LifecycleState.ts new file mode 100644 index 0000000..baf5b69 --- /dev/null +++ b/bindings/LifecycleState.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 provisional → official lifecycle of a heat's result (marshaling Slice 5), projected for the + * UI from the heat FSM + the logged auto-official deadline. + * + * This is **not** a new FSM state: it reuses the heat's existing `Unofficial` (provisional, + * correctable) → `Final` (official) transition. [`Provisional`](Self::Provisional) maps onto + * `Unofficial`; [`Official`](Self::Official) onto `Final`. The optional `auto_official_at` is the + * server-clock instant the runtime will auto-finalize, present only when the round armed a + * [`ProtestWindow::After`](gridfpv_engine::heat::ProtestWindow::After). + */ +export type LifecycleState = { "Provisional": { auto_official_at?: number, } } | "Official"; diff --git a/bindings/LiveRaceState.ts b/bindings/LiveRaceState.ts new file mode 100644 index 0000000..6b3f1f0 --- /dev/null +++ b/bindings/LiveRaceState.ts @@ -0,0 +1,99 @@ +// 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 { LifecycleState } from "./LifecycleState"; +import type { PilotProgress } from "./PilotProgress"; + +/** + * 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. + */ +export type LiveRaceState = { +/** + * 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). When + * `current_heat` is `None` this is [`HeatPhase::Scheduled`] (the idle default). + */ +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, +/** + * The **server-authoritative race-start instant** of the current heat: the + * `recorded_at` (microseconds, server wall clock) of the `Armed → Running` + * transition that put it on the timer (the real race-go). `None` before the heat + * has started (or for an older log whose transition carried no timestamp). + * + * This is the anchor every client clock counts from — header and HUD alike — so the + * elapsed reads identically and accurately *regardless of when the client mounted* + * (the #62 follow-up: kill the per-client wall-clock drift). Renders as a plain TS + * `number` (microseconds, bounded far below 2^53). + */ +race_started_at?: number, +/** + * The **server-authoritative race-end instant** of the current heat: the + * `recorded_at` (microseconds, server wall clock) of the `Running → Unofficial` + * transition that closed it. `None` while the heat is still running (or has not + * started). When set, `race_ended_at - race_started_at` is the **exact** race + * duration a frozen clock reads (so a 60s limit reads `1:00.000`, not `1:00.100`). + * Renders as a plain TS `number` (microseconds). + */ +race_ended_at?: number, +/** + * The **server-authoritative staging-start instant** of the current heat while it is + * `Staged`: the `recorded_at` (microseconds, server wall clock) of its latest + * `Scheduled → Staged` transition. The staging countdown anchors here — a per-client + * wall-clock anchor made every console (and every reload) count its own window, so the + * RD's console could read overtime while a fresh one read the full window. `None` in + * every non-Staged phase. Renders as a plain TS `number` (microseconds). + */ +staged_at?: number, +/** + * The **server-authoritative start-tone instant** of the current heat while it is `Armed`: + * the absolute wall-clock instant (microseconds since the Unix epoch) the start tone fires + * and the heat auto-advances `Armed → Running`. Derived from the `recorded_at` of the heat's + * [`HeatStarting`](gridfpv_events::Event::HeatStarting) event plus its logged `delay_ms` (the + * chosen randomized hold). `None` once the heat is `Running`/past (or before it has armed). + * + * **RD-console-only:** the start delay is *intentionally random to pilots*, so this is the + * anchor the RD's control view counts down to ("tone in 0:03") — the read-only / pilot view + * never renders it. It is `None` once `race_started_at` is set, so a late join after race-go + * sees no stale countdown. Renders as a plain TS `number` (microseconds). + */ +tone_at?: number, +/** + * The **provisional → official lifecycle** of the current heat (marshaling Slice 5, + * marshaling.html §3.3), surfaced for the Marshaling/Live UI. `None` until the heat reaches the + * `Unofficial` phase (before that there is no result to be provisional about). Once provisional + * it carries the auto-official deadline (when a protest window is armed) so the console can show + * a "Provisional — auto-official in M:SS" countdown; `Official` once finalized. Derived from the + * heat's phase + the logged [`HeatFinalizing`](gridfpv_events::Event::HeatFinalizing) deadline, + * so it folds deterministically like the rest of this projection. + */ +lifecycle?: LifecycleState, }; diff --git a/bindings/LogRef.ts b/bindings/LogRef.ts index d023b6b..42a6bd6 100644 --- a/bindings/LogRef.ts +++ b/bindings/LogRef.ts @@ -5,4 +5,4 @@ * marshaling adjudications target (e.g. "void *this* pass"). The offset is assigned * by the storage layer when the target event was appended. */ -export type LogRef = bigint; +export type LogRef = number; diff --git a/bindings/MemberSlot.ts b/bindings/MemberSlot.ts new file mode 100644 index 0000000..840c086 --- /dev/null +++ b/bindings/MemberSlot.ts @@ -0,0 +1,28 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PilotId } from "./PilotId"; + +/** + * One pilot's **slot** within a class's membership (race redesign Slice 7a): the directory pilot + * plus the optional raw-MHz channel they fly in a *static*-channel-mode round. + * + * The channel is the GQ-style **fixed, per-membership** assignment: in a [`ChannelMode::Static`] + * round (time-trial / qualifying), every member flies their own channel and qual heats are + * channel-balanced (one pilot per channel per heat). It is `None` until set, and is unused by a + * [`ChannelMode::PerHeat`] round (the bracket path assigns channels per heat). Validated on set + * against the event's **primary timer**'s `available_channels` — and that pool **can exceed** the + * timer's `node_count`, so any channel in the pool is valid (node_count caps only pilots-per-heat). + * + * Derives serde + `ts_rs::TS` so the frontend reads a generated `MemberSlot` for the Classes & + * Roster channel picker (the UI is the Slice-7b follow-up). + */ +export type MemberSlot = { +/** + * The directory pilot racing this class. + */ +pilot: PilotId, +/** + * The pilot's **fixed assigned channel** (raw MHz) for *static*-channel-mode rounds, or `None` + * when unassigned. Must be one of the event's **primary timer**'s `available_channels` + * (validated on set) — the pool may be larger than `node_count`. + */ +channel?: number, }; diff --git a/bindings/Metric.ts b/bindings/Metric.ts new file mode 100644 index 0000000..1114c07 --- /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": number | null } | { "BestConsecutiveMicros": number | null }; diff --git a/bindings/NewRoundReq.ts b/bindings/NewRoundReq.ts new file mode 100644 index 0000000..1bdddf9 --- /dev/null +++ b/bindings/NewRoundReq.ts @@ -0,0 +1,84 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChannelMode } from "./ChannelMode"; +import type { ClassId } from "./ClassId"; +import type { GraceWindow } from "./GraceWindow"; +import type { ProtestWindow } from "./ProtestWindow"; +import type { SeedingRule } from "./SeedingRule"; +import type { StartProcedure } from "./StartProcedure"; +import type { WinCondition } from "./WinCondition"; + +/** + * The body of `POST /events/{id}/rounds` — everything a caller supplies to add a round (race + * redesign Slice 2a). + * + * The **id is always auto-generated** (a slug of `label` plus a short random suffix), never + * user-entered — mirroring the event/pilot create rule. The [`seeding`](Self::seeding) defaults to + * [`SeedingRule::FromRoster`] when omitted. The route returns the created [`RoundDef`] (with its + * generated [`id`](RoundDef::id)). + */ +export type NewRoundReq = { +/** + * The display label for the new round (e.g. `"Qualifying R1"`). + */ +label: string, +/** + * The eligible classes this round runs for. Each must be one of the event's selected classes. + */ +classes: Array, +/** + * The format this round runs — a known [`FormatRegistry`] name. + */ +format: string, +/** + * The format's config knobs, stored verbatim. + */ +params: { [key in string]: string }, +/** + * How a heat in this round is won. **Optional** (open-practice refinement): an open-practice + * round does no scoring, so the form is not forced to supply one — **omit it** to store the + * inert [`default_win_condition`] ([`WinCondition::BestLap`]). A normal round supplies its + * chosen condition. Additive on the wire (a pre-existing client always sends it). + */ +win_condition?: WinCondition, +/** + * How the round's field is seeded; defaults to [`SeedingRule::FromRoster`] when omitted. + */ +seeding: SeedingRule, +/** + * The **practice duration** in seconds for an open-practice round (open-practice refinement). + * Optional — omit (or leave blank) for **no time limit** (the RD ends the practice with + * `ForceEnd`); supply it to have the runtime auto-end the practice at the limit. Stored on + * [`RoundDef::time_limit_secs`]. Additive on the wire. + */ +time_limit_secs?: number, +/** + * How this round assigns channels (race redesign Slice 7a). Optional — **omit it** to take the + * format's default ([`ChannelMode::default_for_format`]); supply it to override (e.g. force a + * qual round per-heat). Additive on the wire. + */ +channel_mode?: ChannelMode, +/** + * The round's staging timer in seconds (heat-lifecycle Slice 2). Optional — omit for the + * [`default_staging_timer_secs`] (300). Informational only (no auto-advance). + */ +staging_timer_secs?: number, +/** + * The round's start procedure (heat-lifecycle Slice 2). Optional — omit for the + * [`StartProcedure::default`] randomized 2000–5000ms delay. + */ +start_procedure?: StartProcedure, +/** + * The round's grace window (heat-lifecycle Slice 2). Optional — omit for the + * [`default_grace_window`] (a bounded 3s). + */ +grace_window?: GraceWindow, +/** + * The round's protest window (marshaling Slice 5). Optional — omit for the default + * [`ProtestWindow::Off`] (manual finalize only); supply [`ProtestWindow::After`] to arm the + * auto-official timer. + */ +protest_window?: ProtestWindow, +/** + * Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. + */ +min_lap_secs?: number, }; diff --git a/bindings/OptionalEdit.ts b/bindings/OptionalEdit.ts new file mode 100644 index 0000000..ac47f92 --- /dev/null +++ b/bindings/OptionalEdit.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type OptionalEdit = T | null; diff --git a/bindings/ParamKind.ts b/bindings/ParamKind.ts new file mode 100644 index 0000000..d909243 --- /dev/null +++ b/bindings/ParamKind.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. + +/** + * The **kind** of a format parameter (race redesign Slice 7a) — how a UI renders / validates it. + * + * A `number` is a free integer/decimal input; an `enum` is a fixed choice from + * [`options`](FormatParam::options); a `bool` is a toggle. Externally tagged so it maps to a TS + * discriminated-union-friendly string literal. Derives serde + `ts_rs::TS`. + */ +export type ParamKind = "number" | "enum" | "bool"; diff --git a/bindings/Pass.ts b/bindings/Pass.ts index 11fc5c4..20e9780 100644 --- a/bindings/Pass.ts +++ b/bindings/Pass.ts @@ -2,6 +2,7 @@ import type { AdapterId } from "./AdapterId"; import type { CompetitorRef } from "./CompetitorRef"; import type { GateIndex } from "./GateIndex"; +import type { HeatId } from "./HeatId"; import type { SignalContext } from "./SignalContext"; import type { SourceTime } from "./SourceTime"; @@ -26,7 +27,7 @@ at: SourceTime, * passes that share a timestamp and survives clock adjustments; also the basis * for reconnect deduplication. */ -sequence?: bigint, +sequence?: number, /** * The gate crossed; defaults to the lap gate. */ @@ -34,4 +35,13 @@ gate?: GateIndex, /** * Optional signal context (hardware only). */ -signal?: SignalContext, }; +signal?: SignalContext, +/** + * The heat this pass was recorded **for** — stamped by the Director's bridge at append + * time (the adapter doesn't know the heat; the bridge does, since it only routes passes + * while a heat is Running). `None` on a legacy log (pre-tagging), which attributes + * positionally as before. The tag makes attribution robust against heat-span events + * landing mid-race: scheduling/filling another heat used to close the running heat's + * positional span and silently drop its remaining laps from the result. + */ +heat?: HeatId, }; diff --git a/bindings/Penalty.ts b/bindings/Penalty.ts index 5848242..c04cc97 100644 --- a/bindings/Penalty.ts +++ b/bindings/Penalty.ts @@ -1,6 +1,44 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * A marshaling penalty applied to a competitor in a heat. + * A marshaling penalty applied to a competitor in a heat (marshaling.html §3.3 — the + * adjudication framework: DQ, penalty time *and* points, richer than RotorHazard's + * `+time`/note model). + * + * The variants split by **where they land**: + * - [`Disqualify`](Penalty::Disqualify) and [`TimeAdded`](Penalty::TimeAdded) reshape the + * **per-heat** lap/time result (the heat scorer, `gridfpv_engine::scoring`): a DQ sinks a + * competitor below the field, a time penalty worsens their deciding time. + * - [`PointsDeducted`](Penalty::PointsDeducted) / [`PointsAdded`](Penalty::PointsAdded) do + * **not** touch the per-heat lap result — they adjust the competitor's **season / event + * standings** points, folded by the standings projection + * (`gridfpv_server::round_engine::class_standings`), leaving the heat's lap/time/DQ intact. + * + * All variants are reversible via [`RulingReversed`](Event::RulingReversed) and read back on a + * legacy log (the enum is externally tagged, so the new variants are purely additive). + * + * # Legacy `Disqualify` compatibility + * + * Adding the optional `reason` made [`Disqualify`](Penalty::Disqualify) a *struct* variant, + * which serde would otherwise serialise as `{"Disqualify":{}}` and refuse to read from the + * legacy bare string `"Disqualify"`. A hand-written [`Deserialize`] (see the impl below) accepts + * **both** the legacy bare `"Disqualify"` and the struct form, and [`Serialize`] keeps emitting + * the compact bare `"Disqualify"` when there is no reason — so old logs round-trip byte-for-byte + * and a reason-less DQ stays on the wire exactly as before. */ -export type Penalty = "Disqualify" | { "TimeAdded": { micros: bigint, } }; +export type Penalty = { "Disqualify": { +/** + * Why the competitor was disqualified (e.g. "cut the course", "unsafe flying"). `None` + * when no reason was recorded — the common quick-DQ, and the legacy shape. (`Penalty` + * hand-rolls serde — see the impls below — so the optional/skip behaviour lives there; + * `#[ts(optional)]` keeps the generated TS field `reason?:`.) + */ +reason?: string, } } | { "TimeAdded": { micros: number, } } | { "PointsDeducted": { +/** + * How many standings points to subtract (saturating at zero). + */ +points: number, } } | { "PointsAdded": { +/** + * How many standings points to add. + */ +points: number, } }; diff --git a/bindings/Pilot.ts b/bindings/Pilot.ts new file mode 100644 index 0000000..f745297 --- /dev/null +++ b/bindings/Pilot.ts @@ -0,0 +1,80 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PilotId } from "./PilotId"; +import type { VtxType } from "./VtxType"; + +/** + * One pilot in the application-level directory (issue #74). + * + * The wire shape `GET /pilots` returns and the on-disk shape `pilots.json` persists: a stable + * [`PilotId`] (auto-generated, never user-entered), a required `callsign`, and a little optional + * metadata. The optional fields are all omitted from the wire when unset (`skip_serializing_if`) + * so an entry with just a callsign serialises to a two-field object. Derives serde (its JSON *is* + * both the wire and the persisted form) and `ts_rs::TS` so the frontend reads a generated `Pilot` + * type. + * + * # Directory fields (a survey of RotorHazard / MultiGP / FPVScores, #74/#120) + * + * Beyond the core callsign + cloud-pull ids, the directory carries a small set of + * **display/organizer** fields those systems converge on: a [`phonetic`](Pilot::phonetic) + * pronunciation hint for voice callouts (RotorHazard), a [`team`](Pilot::team) / club name, a + * [`color`](Pilot::color) for overlays/leaderboards, and a [`country`](Pilot::country) code. + */ +export type Pilot = { +/** + * The stable handle a roster references and the API addresses (`PUT /pilots/{id}`). The same + * [`PilotId`] the log's registration binding uses — directory and log never disagree on what a + * pilot id is. + */ +id: PilotId, +/** + * The pilot's **callsign** — the required display handle (their racing name). The directory's + * one mandatory field; everything else is optional metadata. + */ +callsign: string, +/** + * The pilot's real / full name, if recorded. Omitted from the wire when unset. + */ +name?: string, +/** + * A **pronunciation hint** for the callsign, for voice callouts (RotorHazard carries this). + * Free-form (e.g. `"AK-ro AYS"`). Omitted from the wire when unset. + */ +phonetic?: string, +/** + * The pilot's **team / club** name, if recorded. Free-form. Omitted from the wire when unset. + */ +team?: string, +/** + * A **hex color** `#RRGGBB` for overlays / leaderboards, if recorded. Stored as a plain + * (normalized) string and lightly validated on create/update. Omitted from the wire when unset. + */ +color?: string, +/** + * The pilot's **country** as an ISO 3166-1 alpha-2 code (e.g. `US`, `GB`), if recorded. The + * **code only** — flags/names derive from it in the UI. Stored uppercase, lightly validated. + * Omitted from the wire when unset. + */ +country?: string, +/** + * The pilot's **video-transmitter system(s)** (see [`VtxType`]). An FPV pilot always flies + * *some* video system, and many run more than one (e.g. Analog + HDZero), so this is a **set** + * rather than a single optional value. Empty means *unspecified*. Kept deduplicated and in a + * stable [`VtxType::ORDER`] on every create/update. Defaults empty (and, being a `Vec`, an + * empty set still serializes to `[]`). + * + * **Persisted-format note:** the old on-disk shape was a single optional scalar + * `vtx_type: VtxType`. A custom deserializer (see [`deserialize_vtx_types`]) accepts both the + * new `vtx_types: [..]` array and migrates a legacy `vtx_type: X` into `[X]`, so existing + * `pilots.json` rows load without data loss. + */ +vtx_types: Array, +/** + * The pilot's **MultiGP** pilot id, if known — a forward hook for a later cloud-pull import + * (#74). A free-form string. Omitted from the wire when unset. + */ +multigp_id?: string, +/** + * The pilot's **Velocidrone** id, if known — a forward hook for matching a Velocidrone racer + * (#74). A free-form string. Omitted from the wire when unset. + */ +velocidrone_id?: string, }; diff --git a/bindings/PilotId.ts b/bindings/PilotId.ts new file mode 100644 index 0000000..9d78201 --- /dev/null +++ b/bindings/PilotId.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. + +/** + * A GridFPV pilot's stable, event-scoped identity — the racer a source-local + * [`CompetitorRef`] is *bound* to by a registration action (Architecture §9), never by + * the adapter. For a basic race this is the pilot's callsign / stable id; richer pilot + * metadata (display name, team, avatar) can layer on later. + * + * This is the canonical pilot handle the whole stack shares: the event model records + * the binding ([`Event::CompetitorRegistered`]) and the wire/scope layer addresses a + * pilot by the same type, so the log and the protocol never disagree on what a pilot id + * is. + */ +export type PilotId = string; diff --git a/bindings/PilotProgress.ts b/bindings/PilotProgress.ts new file mode 100644 index 0000000..f0ed447 --- /dev/null +++ b/bindings/PilotProgress.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 { CompetitorRef } from "./CompetitorRef"; +import type { PilotId } from "./PilotId"; + +/** + * 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, +/** + * The GridFPV pilot this competitor is bound to, if a registration + * ([`Event::CompetitorRegistered`]) has bound it (#60). `None` for an unregistered + * competitor, which still appears by its bare [`CompetitorRef`]. + */ +pilot?: PilotId, +/** + * 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. Renders as a plain TS `number` (bounded far below 2^53). + */ +last_lap_micros?: number, }; diff --git a/bindings/Placement.ts b/bindings/Placement.ts new file mode 100644 index 0000000..9b81374 --- /dev/null +++ b/bindings/Placement.ts @@ -0,0 +1,50 @@ +// 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, +/** + * The competitor's **fastest single completed lap** in this heat, in microseconds, or + * `None` if they completed no timed lap. Computed from the run's lap durations + * **independently of the win condition** (so e.g. a [`WinCondition::FirstToLaps`] + * placement still carries a real best-lap, not the win metric), with thrown-out laps + * excluded. Cross-heat rankings use it to break ties — round-robin breaks pilots equal + * on points by their faster best lap. Defaults to `None` so older snapshots that + * predate the field still deserialise. + */ +best_lap_micros: number | null, +/** + * 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/bindings/PluginPresence.ts b/bindings/PluginPresence.ts new file mode 100644 index 0000000..79e0928 --- /dev/null +++ b/bindings/PluginPresence.ts @@ -0,0 +1,37 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Whether a connected RotorHazard timer carries the **GridFPV plugin** (RH plugin design D16, + * Slice 1) — the in-process integration the Director probes for over the existing socket.io + * connection (`gridfpv_hello` → `gridfpv_hello_ack`). Carried as an `Option` on [`Timer`]: + * `None` is "not probed" (a Mock timer, or an RH not yet connected). Like [`TimerStatus`] it is a + * **live, in-memory** value, not persisted (reset to `None` on load and on reconfigure) and + * **additive on the wire** (`#[ts(optional)]` — an older console still parses a `Timer`). The + * Director uses it to drive the required-with-guided-install UX (§5): `Missing` and `Incompatible` + * surface the one-step install; `Present` surfaces a healthy timer. + */ +export type PluginPresence = "Missing" | { "Present": { +/** + * The plugin build version (e.g. `"0.1.0"`). + */ +plugin_version: string, +/** + * The RHAPI version the plugin reported (e.g. `"1.4"`). + */ +rhapi_version: string, +/** + * The capabilities the plugin declared (e.g. `["hello"]`). + */ +capabilities: Array, } } | { "Incompatible": { +/** + * The plugin build version, so the UI can name the mismatch. + */ +plugin_version: string, +/** + * The plugin's `gridfpv_*` protocol version (the field that didn't match). + */ +protocol_version: number, +/** + * A short plain-language reason for the mismatch. + */ +reason: string, } }; diff --git a/bindings/ProjectionBody.ts b/bindings/ProjectionBody.ts new file mode 100644 index 0000000..b47fde6 --- /dev/null +++ b/bindings/ProjectionBody.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AuditEntry } from "./AuditEntry"; +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"; +import type { SignalTraceView } from "./SignalTraceView"; + +/** + * 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 } | { "MarshalingAudit": Array } | { "SignalTrace": SignalTraceView }; diff --git a/bindings/ProjectionKind.ts b/bindings/ProjectionKind.ts new file mode 100644 index 0000000..992cbcd --- /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" | "MarshalingAudit" | "SignalTrace"; diff --git a/bindings/ProtestOutcome.ts b/bindings/ProtestOutcome.ts new file mode 100644 index 0000000..b888ad2 --- /dev/null +++ b/bindings/ProtestOutcome.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. + +/** + * How a [`ProtestFiled`](Event::ProtestFiled) was resolved (marshaling.html §3.3). A small, + * closed enum recorded by [`ProtestResolved`](Event::ProtestResolved); externally tagged on the + * wire like the rest of the event vocabulary, so it maps to a TS string union. + */ +export type ProtestOutcome = "Upheld" | "Denied" | "Withdrawn"; diff --git a/bindings/ProtestWindow.ts b/bindings/ProtestWindow.ts new file mode 100644 index 0000000..b4dcecc --- /dev/null +++ b/bindings/ProtestWindow.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. + +/** + * The **protest window** for the provisional → official lifecycle (marshaling Slice 5, + * marshaling.html §3.3): an optional, OFF-by-default **auto-official timer**. + * + * A heat sits in [`Unofficial`](HeatState::Unofficial) (provisional, correctable) after the race + * closes. When a protest window is configured, the runtime **auto-finalizes** it + * (`Unofficial → Final`) once the window elapses from the race-end instant; the RD can always + * finalize early (manually) or correct during the window, and [`Revert`](HeatCommand::Revert) + * re-opens a finalized result. This is **not** a gate that blocks `Finalize` — it is an additive + * auto-finalize. The default ([`Off`](Self::Off)) is today's behaviour: manual `Finalize` only. + * + * Derives serde + `ts_rs::TS` so it can be carried as a per-round config + * ([`RoundDef::protest_window`](../../server/events/struct.RoundDef.html)) and read by the + * frontend; it mirrors [`GraceWindow`]'s shape (an off variant + a bounded duration). + */ +export type ProtestWindow = "Off" | { "After": { +/** + * Length of the protest window, in microseconds (server wall clock), counted from the + * `Running → Unofficial` race-end instant. + */ +micros: number, } }; 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/RoundDef.ts b/bindings/RoundDef.ts new file mode 100644 index 0000000..28d51f1 --- /dev/null +++ b/bindings/RoundDef.ts @@ -0,0 +1,140 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChannelMode } from "./ChannelMode"; +import type { ClassId } from "./ClassId"; +import type { GraceWindow } from "./GraceWindow"; +import type { ProtestWindow } from "./ProtestWindow"; +import type { RoundId } from "./RoundId"; +import type { SeedingRule } from "./SeedingRule"; +import type { StartProcedure } from "./StartProcedure"; +import type { WinCondition } from "./WinCondition"; + +/** + * One **round** within an event (race redesign Slice 2a): an event-level, class-tagged, *dynamic* + * format-instance. + * + * A round is a *format-instance* — a named, configured run of one + * [`FormatRegistry`](gridfpv_engine::format::FormatRegistry) format, scoped to the eligible + * [`classes`](Self::classes) it runs for, with a [`SeedingRule`] deciding how its field is drawn. + * One eligible class is a **class round** (e.g. "Open Qualifying"); many/all classes is an + * **open / practice** round. Rounds are added **as-you-go** (practice/quali) rather than + * precomputed; later slices seed brackets from a prior round's ranking + * ([`SeedingRule::FromRanking`]). + * + * Carried in [`EventMeta::rounds`]. Derives serde (its JSON *is* the wire form) and `ts_rs::TS` so + * the frontend reads a generated `RoundDef` type (the Rounds UI lands in Slice 2b). + */ +export type RoundDef = { +/** + * The stable, **auto-generated** handle for this round (a slug of the [`label`](Self::label) + * plus a short random suffix — mirroring the event/pilot id-gen). Never user-entered; the + * label is display-only. Referenced by a later round's [`SeedingRule::FromRanking`]. + */ +id: RoundId, +/** + * The human-readable label (e.g. `"Qualifying R1"`, `"Open Practice"`). Display-only; the + * [`id`](Self::id) is authoritative. + */ +label: string, +/** + * The eligible [`classes`](EventMeta::classes) this round runs for. One class is a *class + * round*; many/all is an *open / practice* round. Each must be one of the event's selected + * classes. + */ +classes: Array, +/** + * The format this round runs — a known + * [`FormatRegistry`](gridfpv_engine::format::FormatRegistry) name (e.g. `"timed_qual"`, + * `"single_elim"`). Validated against [`FormatRegistry::standard`] on add/update. + */ +format: string, +/** + * The format's config knobs (e.g. `rounds`, `advance`, `heat_size`), stored as-is as a + * string→string map — the same shape a `FormatConfig`'s params take. Stored verbatim with only + * light validation; format-specific interpretation is the engine's concern when the round runs + * (a later slice). + */ +params: { [key in string]: string }, +/** + * How a heat in this round is won — the per-round scoring rule (the existing wire + * [`WinCondition`](gridfpv_engine::scoring::WinCondition)). + * + * **Open practice does no scoring**, so a win condition is not *required* for an open-practice + * round: the create/update requests make this field optional ([`NewRoundReq::win_condition`]) + * and an omitted condition stores an inert [`default_win_condition`] here. The field stays a + * plain [`WinCondition`] (not an `Option`) so every scoring path is unchanged — for a + * non-open-practice round the stored condition is the one the RD chose; for an open-practice + * round it is never consulted (the heat ends on the [`time_limit_secs`](Self::time_limit_secs) + * or the RD's `ForceEnd`). + */ +win_condition: WinCondition, +/** + * How this round's field is **seeded** (drawn). Defaults to [`SeedingRule::FromRoster`] (the + * eligible classes' membership, in roster order); a bracket round seeds from a prior round's + * ranking ([`SeedingRule::FromRanking`], consumed in a later slice). + */ +seeding: SeedingRule, +/** + * How this round assigns **video channels** to its heats (race redesign Slice 7a). A + * [`ChannelMode::Static`] round (time-trial / qual, GQ-style) uses each member's *fixed* + * per-membership channel ([`MemberSlot::channel`]) and forms channel-balanced heats; a + * [`ChannelMode::PerHeat`] round (brackets) assigns channels per heat from the timer's pool + * (first-fit). Defaulted **by format** on create (`#[serde(default)]` so pre-Slice-7a meta + * reads back as [`ChannelMode::PerHeat`], the prior behaviour); RD-overridable. + */ +channel_mode: ChannelMode, +/** + * The **staging timer** for this round, in seconds (heat-lifecycle Slice 2). *Informational + * only* — there is **no** auto-advance out of `Staged`; the console displays it as a staging + * countdown (Slice 3). Defaults to [`default_staging_timer_secs`] (300s = 5 min). Additive + * (`#[serde(default)]`) so pre-Slice-2 meta reads back with the default. + */ +staging_timer_secs: number, +/** + * The **start procedure** for this round (heat-lifecycle Slice 2) — how the heat auto-advances + * `Armed → Running`. The runtime picks a randomized delay in the procedure's window once, logs + * it ([`Event::HeatStarting`](gridfpv_events::Event::HeatStarting)), and fires the transition + * then. Defaults to a sane randomized delay ([`StartProcedure::default`]). Additive. + */ +start_procedure: StartProcedure, +/** + * The **grace window** for late crossings after the win condition is met (heat-lifecycle + * Slice 2). The runtime holds the heat in `Running` for this long after the race-end criterion + * before appending the auto `Running → Unofficial`, so trailing pilots' final laps still count. + * Defaults to [`default_grace_window`] (a bounded few seconds — *not* `UntilScored`, so the + * auto-completion actually fires). Additive. + */ +grace_window: GraceWindow, +/** + * The **protest window** for the provisional → official lifecycle (marshaling Slice 5, + * marshaling.html §3.3) — an optional, **OFF-by-default auto-official timer**. When set to + * [`ProtestWindow::After`], the runtime auto-finalizes the heat (`Unofficial → Final`) once the + * window elapses from the race-end instant; the RD can always finalize early or correct during + * the window, and `Revert` re-opens a finalized result. The default [`ProtestWindow::Off`] is + * today's behaviour — **manual `Finalize` only**, nothing auto-finalizes. + * + * Per-round so it can vary by phase (e.g. a protest window on the mains, none on practice). + * Additive (`#[serde(default)]`) so a round persisted before this field reads back as `Off`. + */ +protest_window: ProtestWindow, +/** + * The **minimum lap time** floor, in seconds (D26) — GridFPV-native, because timers are + * dumb pass emitters and GridFPV owns lap semantics: a raw pass that would close a lap + * shorter than this (a gate reflection, a double-detection) is AUTO-SUPPRESSED by the + * corrected-passes fold — visible on the marshaling lap list as a struck removal-record + * row with a Restore override (marshal-created passes — inserts, re-times — are exempt: + * an explicit ruling always outranks the floor). `None`/`0` = off (rounds predating the + * field keep their scored results bit-identical). + */ +min_lap_secs?: number, +/** + * The **practice duration** for an open-practice round, in seconds (open-practice refinement). + * When set, the runtime clock **auto-ends the practice** (`Running → Unofficial`) once the + * heat's elapsed running time reaches this limit — independent of any win condition (the time is + * the *only* end condition for an open-practice heat). When unset (`None`), the practice runs + * until the RD ends it (`ForceEnd`). E.g. `3600` ends a one-hour practice on its own. + * + * Additive (`#[serde(default)]`, omitted from the wire when unset) so a round persisted before + * this field reads back with `None`. Only consulted for an open-practice heat; a normal round + * keeps ending on its win condition. + */ +time_limit_secs?: number, }; diff --git a/bindings/RoundId.ts b/bindings/RoundId.ts new file mode 100644 index 0000000..0d8d9cf --- /dev/null +++ b/bindings/RoundId.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Identifies a **round** within a class's schedule — one pass through the phase + * (qualifying round 1, round 2, …). A transparent string newtype like [`ClassId`]; + * the richer phase/round model lands with the scheduler, this is the stable handle a + * scheduled heat is tagged with. + */ +export type RoundId = string; diff --git a/bindings/RoundMetric.ts b/bindings/RoundMetric.ts new file mode 100644 index 0000000..4ab7e69 --- /dev/null +++ b/bindings/RoundMetric.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. + +/** + * The **win-condition metric** a round's standings are ranked by — the tagged mirror of the + * round's [`win_condition`](RoundDef::win_condition), carrying the value the ranking is *by*. + * + * This is the headline number the Rounds stage shows next to each pilot: the fastest single lap + * ([`BestLap`](RoundMetric::BestLap)), the fastest N-consecutive-lap window + * ([`BestConsecutive`](RoundMetric::BestConsecutive)), or the most laps banked + * ([`MostLaps`](RoundMetric::MostLaps)) — mapped from the win condition exactly as + * [`qual_metric_for`] derives the qualifying metric. The lap-time variants carry `None` for a + * pilot who set no qualifying value (no lap / fewer than `n` laps), so a no-show still renders. + */ +export type RoundMetric = { "BestLap": { +/** + * Fastest single lap (µs), or `None` for no completed lap. + */ +micros: number | null, } } | { "BestConsecutive": { +/** + * How many consecutive laps the window spans. + */ +n: number, +/** + * Smallest consecutive-window sum (µs), or `None` if fewer than `n` laps. + */ +micros: number | null, } } | { "MostLaps": { +/** + * Most laps in a heat (0 if the pilot completed no lap). + */ +laps: number, } }; diff --git a/bindings/RoundStanding.ts b/bindings/RoundStanding.ts new file mode 100644 index 0000000..e618ddd --- /dev/null +++ b/bindings/RoundStanding.ts @@ -0,0 +1,40 @@ +// 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 { RoundMetric } from "./RoundMetric"; + +/** + * One pilot's **per-round standing** for the Rounds stage's time-trial (timed_qual) display. + * + * Built by [`round_standings`] for a single round: each pilot's [`position`](RoundStanding::position) + * (exactly the [`round_ranking`] order, so the standings and the ranking never disagree), their + * **best single lap** ([`best_lap_micros`](RoundStanding::best_lap_micros), *always* computed so the + * UI can show a Best-lap column regardless of win condition), their **most laps in a heat** + * ([`laps`](RoundStanding::laps)), and the win-condition [`metric`](RoundStanding::metric) the + * ranking is by. Pure + deterministic — the same log + meta always yields the same standings. + */ +export type RoundStanding = { +/** + * The competitor this standing is for (the pilot's source-local handle). + */ +competitor: CompetitorRef, +/** + * 1-based position; tied competitors share a position with the same dense, tie-aware + * "1, 2, 2, 4" convention as [`RankEntry`] — and exactly matches [`round_ranking`]. + */ +position: number, +/** + * The pilot's **best single lap** across the round's heats, in microseconds, or `None` when + * they completed no lap. *Always* computed (independent of the win condition) so a Best-lap + * column can sit alongside the ranking metric. + */ +best_lap_micros: number | null, +/** + * The most laps the pilot banked in any single heat (under the round's win condition) — the + * most-laps metric value and lap-count context. `0` for a pilot who completed no lap. + */ +laps: number, +/** + * The win-condition metric the ranking is *by* (the tagged mirror of the round's win + * condition). `best_lap_micros` is always present alongside this. + */ +metric: RoundMetric, }; 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/SeedingRule.ts b/bindings/SeedingRule.ts new file mode 100644 index 0000000..765031a --- /dev/null +++ b/bindings/SeedingRule.ts @@ -0,0 +1,51 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RoundId } from "./RoundId"; + +/** + * How a [`RoundDef`]'s field is **seeded** (race redesign Slice 2a). + * + * A round either draws its field straight from the eligible classes' roster membership + * ([`FromRoster`](Self::FromRoster) — practice / first qualifying), or from a **prior round's + * ranking** ([`FromRanking`](Self::FromRanking) — the bracket / cut case, the issue-#84 carry that + * a later slice consumes), or — for the casual **open-practice** format — from a set of active + * **channels** ([`AllChannels`](Self::AllChannels)) rather than pilots. Derives serde + `ts_rs::TS`. + */ +export type SeedingRule = "FromRoster" | { "FromRanking": { +/** + * The prior rounds this round seeds from — each must exist in [`EventMeta::rounds`]. + * Aggregated best-per-pilot when more than one is named. Always at least one entry. + */ +source_rounds: Array, +/** + * How many of the aggregated ranking's top places advance into this round. + */ +top_n: number, } } | { "FromHeatWinners": { +/** + * The prior bracket-level round whose heat winners seed this round — must exist in + * [`EventMeta::rounds`]. + */ +source_round: RoundId, } } | { "FromRankingRange": { +/** + * The prior rounds this round seeds from — each must exist in [`EventMeta::rounds`]. + * Aggregated best-per-pilot when more than one is named. Always at least one entry. + */ +source_rounds: Array, +/** + * How many of the aggregated ranking's leading places to **skip** before taking — the + * 0-based start of the window (seed 13 is `skip: 12`). + */ +skip: number, +/** + * How many places to take after the skip — the window width. Must be `> 0`. + */ +take: number, } } | { "Combine": { +/** + * The sub-rules whose fields are concatenated (in order) then de-duplicated first-wins. + * Each is resolved exactly as a standalone seeding rule. Always at least one entry. + */ +sources: Array, } } | { "AllChannels": { +/** + * The active channels as **node indices** (the timer-seat indices the RD made live), laid + * out as `node-{i}` competitor refs by the field builder, in this order. + */ +channels: Array, } }; 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/SetActiveEventRequest.ts b/bindings/SetActiveEventRequest.ts new file mode 100644 index 0000000..11638aa --- /dev/null +++ b/bindings/SetActiveEventRequest.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. +import type { EventId } from "./EventId"; + +/** + * The body of `PUT /active-event` — the id of the event to make the Director's active one + * (issue #90). The id must name a known event, else a typed 404 (`UnknownScope`). + */ +export type SetActiveEventRequest = { +/** + * The event to make active. + */ +id: EventId, }; diff --git a/bindings/SetClassHiddenRequest.ts b/bindings/SetClassHiddenRequest.ts new file mode 100644 index 0000000..b36203a --- /dev/null +++ b/bindings/SetClassHiddenRequest.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 body of `PUT /classes/{id}/hidden` — the new visibility for a class (hide/archive classes). + * + * A one-field body: `hidden: true` tucks the class away from the per-event picker, `false` brings + * it back. Applies to **built-in** and custom classes alike (hiding is a visibility preference, not + * an edit), so it is valid even on a read-only built-in. + */ +export type SetClassHiddenRequest = { +/** + * The desired visibility: `true` → hidden (archived from the picker), `false` → visible. + */ +hidden: boolean, }; diff --git a/bindings/SetClassMembershipRequest.ts b/bindings/SetClassMembershipRequest.ts new file mode 100644 index 0000000..55f87df --- /dev/null +++ b/bindings/SetClassMembershipRequest.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MemberSlot } from "./MemberSlot"; + +/** + * The body of `PUT /events/{id}/classes/{class}/membership` — the roster pilot ids that race a + * single class (race redesign Slice 1a). The `class` is the path segment; each id here must name + * a pilot in the [`PilotDirectory`](crate::pilots::PilotDirectory), else a typed 404. An empty + * list clears the class's membership. + */ +export type SetClassMembershipRequest = { +/** + * The roster pilots that race this class, in selection order, **each with an optional assigned + * channel** (race redesign Slice 7a). Each must name a known pilot; each set + * [`channel`](MemberSlot::channel) must be one of the event's **primary timer**'s + * `available_channels` (which may exceed the timer's `node_count`). + * + * **Legacy-compatible:** an element may be a bare pilot-id string (the pre-Slice-7a wire shape), + * read as a channel-less slot — so an old client / persisted body still sets membership. + */ +pilots: Array, }; diff --git a/bindings/SetEventClassesRequest.ts b/bindings/SetEventClassesRequest.ts new file mode 100644 index 0000000..e68596d --- /dev/null +++ b/bindings/SetEventClassesRequest.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. +import type { ClassId } from "./ClassId"; + +/** + * The body of `PUT /events/{id}/classes` — the directory class ids that make up an event's + * selection (issue #84). Each must name a class in the application-level + * [`ClassDirectory`](crate::classes::ClassDirectory), else a typed 404. + */ +export type SetEventClassesRequest = { +/** + * The directory classes this event runs, in selection order. Each must name a known class. + */ +ids: Array, }; diff --git a/bindings/SetEventRosterRequest.ts b/bindings/SetEventRosterRequest.ts new file mode 100644 index 0000000..bd41111 --- /dev/null +++ b/bindings/SetEventRosterRequest.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. +import type { PilotId } from "./PilotId"; + +/** + * The body of `PUT /events/{id}/roster` — the directory pilot ids that make up an event's + * roster (issue #74). Each must name a pilot in the application-level + * [`PilotDirectory`](crate::pilots::PilotDirectory), else a typed 404. + */ +export type SetEventRosterRequest = { +/** + * The directory pilots this event rosters, in selection order. Each must name a known pilot. + */ +pilot_ids: Array, }; diff --git a/bindings/SetEventTimersRequest.ts b/bindings/SetEventTimersRequest.ts new file mode 100644 index 0000000..042323e --- /dev/null +++ b/bindings/SetEventTimersRequest.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 { TimerId } from "./TimerId"; + +/** + * The body of `PUT /events/{id}/timers` — the timer ids an event selects (issue #73), and + * optionally which of them is the **primary** (issue #112). + */ +export type SetEventTimersRequest = { +/** + * The timers this event uses, in selection order. Each must name a known timer. + */ +ids: Array, +/** + * The **primary** timer among `ids` (issue #112): the timer whose passes feed the log while + * healthy, the rest being hot-standby alternates. Optional and additive — omit it to leave the + * primary defaulting to the first selected timer. When given, it must be one of `ids`. + */ +primary?: TimerId, }; diff --git a/bindings/SetPrimaryTimerRequest.ts b/bindings/SetPrimaryTimerRequest.ts new file mode 100644 index 0000000..0311521 --- /dev/null +++ b/bindings/SetPrimaryTimerRequest.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. +import type { TimerId } from "./TimerId"; + +/** + * The body of `PUT /events/{id}/primary-timer` — designate which selected timer is the + * **primary** (issue #112), the rest being alternates. The `id` must be one of the event's + * currently-selected timers; `null` clears the override (the first selected timer becomes primary). + */ +export type SetPrimaryTimerRequest = { +/** + * The timer to make primary, or `null` to clear the override (default to the first selected). + */ +id?: TimerId, }; diff --git a/bindings/SignalChunk.ts b/bindings/SignalChunk.ts new file mode 100644 index 0000000..ff19057 --- /dev/null +++ b/bindings/SignalChunk.ts @@ -0,0 +1,53 @@ +// 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 { SourceTime } from "./SourceTime"; + +/** + * A contiguous run of per-tick RSSI samples captured from a signal-capable timer + * (RotorHazard first — marshaling.html §3.2, the signal-as-evidence layer). + * + * The trace is the **evidence** a marshal later reviews a call against. It is captured + * **chunked and append-incrementally** rather than one fat per-heat blob: a hardware timer + * streams its node RSSI continuously, so chunks append as the run proceeds and interleave + * deterministically with the [`Pass`]es in the same log — a single buffered-until-heat-end + * event would be lost on an abort. Each chunk is a window of `rssi` samples beginning at + * `from` on the source clock, one every `period_micros`; concatenating a competitor's chunks + * in append order reconstructs the whole trace (the projection's job, see + * `gridfpv_projection::signal_trace`). + * + * `rssi` is kept as `u16` — RotorHazard's filtered ADC counts are integers, so this is + * compact, exact, and free of the float-equality hazards a `f32` trace would carry into the + * deterministic fold/tests (matching the events crate's integer-time convention). + * + * # Fidelity bound + * + * RotorHazard's streaming `node_data` socket emit carries only the **latest** per-node + * sample (`pass_peak_rssi`/`node_peak_rssi`), not a backfilled per-tick history array (that + * lives in the request-driven `current_marshal_data`, which a live translator does not + * subscribe to). So a chunk captured from the live stream samples at the emit cadence — one + * `rssi` value per `node_data` tick — which is faithful to *what RH exposes live*, but is + * coarser than the detector's internal sampling. This is the load-bearing fidelity bound + * (marshaling.html §4) to confirm on real hardware. + */ +export type SignalChunk = { +/** + * The timing source that produced this trace. + */ +adapter: AdapterId, +/** + * The source-local competitor (node seat) the trace belongs to. + */ +competitor: CompetitorRef, +/** + * The source-clock timestamp of the **first** sample in `rssi`. + */ +from: SourceTime, +/** + * Microseconds between consecutive samples (the capture cadence). + */ +period_micros: number, +/** + * The RSSI samples (filtered ADC counts), oldest first, one every `period_micros`. + */ +rssi: Array, }; diff --git a/bindings/SignalHistory.ts b/bindings/SignalHistory.ts new file mode 100644 index 0000000..67649e7 --- /dev/null +++ b/bindings/SignalHistory.ts @@ -0,0 +1,45 @@ +// 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"; + +/** + * The **dense, full-fidelity** per-node RSSI history for a heat — the detector's own internal + * sampling, pulled from RotorHazard's request-driven `current_marshal_data` at heat end + * (marshaling.html §3.2, the "RSSI fidelity" risk in §4). + * + * Unlike the live-streamed [`SignalChunk`] (one aggregate sample per `node_data` heartbeat emit, + * the *coarse* trace), this is the trace RotorHazard's own marshal page reviews against: every + * per-tick sample the node recorded, with its **own** sample time. A signal-capable adapter pulls + * it once when a heat reaches `DONE` and appends one `SignalHistory` per node. The `signal_trace` + * projection **prefers** this dense history over the coarse [`SignalChunk`] samples for a + * competitor when both are present (the dense trace supersedes the streaming approximation). + * + * # Why explicit per-sample times (not a uniform cadence) + * + * [`SignalChunk`] assumes a fixed `period_micros` because the live stream emits on a regular + * heartbeat. The detector's internal history is **not** guaranteed uniform — RotorHazard reports a + * parallel `history_times`/`history_values` pair — so this carries the per-sample times directly + * (`times[i]` is the source-clock instant of `rssi[i]`), preserving native fidelity with **no + * resampling** (the load-bearing fidelity caution, marshaling.html §4). Times are race-relative + * microseconds on the same clock as [`Pass::at`] and the chunk time base, so lap markers and the + * dense trace align. + */ +export type SignalHistory = { +/** + * The timing source that produced this history. + */ +adapter: AdapterId, +/** + * The source-local competitor (node seat) the history belongs to. + */ +competitor: CompetitorRef, +/** + * The source-clock timestamp (race-relative µs) of each sample, parallel to `rssi`. Same + * length as `rssi`; `times[i]` is when `rssi[i]` was recorded. Renders as TS `number[]` + * (bounded far below 2^53). + */ +times: number[], +/** + * The dense per-tick RSSI samples (filtered ADC counts), parallel to `times`. + */ +rssi: Array, }; diff --git a/bindings/SignalThresholds.ts b/bindings/SignalThresholds.ts new file mode 100644 index 0000000..e9ed7be --- /dev/null +++ b/bindings/SignalThresholds.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 { AdapterId } from "./AdapterId"; +import type { CompetitorRef } from "./CompetitorRef"; + +/** + * The enter/exit detection thresholds a signal-capable timer is configured with for a node — + * the levels the RSSI crosses to open/close a pass (RotorHazard `enter_at_level` / + * `exit_at_level`). Captured **once** alongside the [`SignalChunk`] trace so a marshal can + * see *why* the timer called (or missed) a lap against the visible signal (marshaling.html + * §3.2). One-shot per `(adapter, competitor)`; a later one supersedes (the projection keeps + * the last). + */ +export type SignalThresholds = { +/** + * The timing source these thresholds belong to. + */ +adapter: AdapterId, +/** + * The source-local competitor (node seat) the thresholds apply to. + */ +competitor: CompetitorRef, +/** + * The **enter** threshold: RSSI rising above this opens a pass. + */ +enter: number, +/** + * The **exit** threshold: RSSI falling below this closes the pass. + */ +exit: number, }; diff --git a/bindings/SignalTraceView.ts b/bindings/SignalTraceView.ts new file mode 100644 index 0000000..5dcb36e --- /dev/null +++ b/bindings/SignalTraceView.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. +import type { CompetitorTrace } from "./CompetitorTrace"; + +/** + * The signal-trace read model for a heat: one [`CompetitorTrace`] per competitor that produced + * any signal facts, ordered deterministically by [`CompetitorKey`] (marshaling Slice 1). + */ +export type SignalTraceView = { +/** + * Per-competitor traces, ordered by competitor key. + */ +competitors: Array, }; 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/SourceTime.ts b/bindings/SourceTime.ts index 5d2519b..3238f29 100644 --- a/bindings/SourceTime.ts +++ b/bindings/SourceTime.ts @@ -10,4 +10,4 @@ * Director's session axis separately. Integer microseconds keep interval math exact * and comparisons stable (no float-equality hazards in tests). */ -export type SourceTime = bigint; +export type SourceTime = number; diff --git a/bindings/StartProcedure.ts b/bindings/StartProcedure.ts new file mode 100644 index 0000000..c3a9f4d --- /dev/null +++ b/bindings/StartProcedure.ts @@ -0,0 +1,32 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { StartTone } from "./StartTone"; + +/** + * The **start procedure** that drives a heat's `Armed → Running` auto-transition (heat-lifecycle + * Slice 2). + * + * An **extensible** enum (today the one [`RandomizedDelay`](Self::randomized-delay) mode): the + * runtime reads it when a heat enters `Armed`, picks a delay in the procedure's window **once**, + * writes it to the log as a fact ([`Event::HeatStarting`](gridfpv_events::Event::HeatStarting)), + * and schedules the `Running` transition for then. Randomization happens only in the runtime at + * emission time, never in the fold, so a replay reads the logged delay and reproduces the start + * exactly (race-engine.html §6). Derives serde + `ts_rs::TS`. + * + * Serialized **internally tagged** on `mode` (e.g. `{ "mode": "randomized-delay", ... }`) so a + * future mode (a fixed countdown, an external arm-tone trigger) is an additive variant. + */ +export type StartProcedure = { "mode": "randomized-delay", +/** + * The shortest the runtime will hold before `Running`, in milliseconds. + */ +min_delay_ms: number, +/** + * The longest the runtime will hold before `Running`, in milliseconds. Must be ≥ + * `min_delay_ms` (the runtime clamps a mis-ordered pair to a point delay defensively). + */ +max_delay_ms: number, +/** + * Optional start-tone cue config for the console (heat-lifecycle Slice 3 renders/plays it; + * stored here now). Absent ⇒ the console uses its default tone. + */ +tone?: StartTone, }; diff --git a/bindings/StartTone.ts b/bindings/StartTone.ts new file mode 100644 index 0000000..ffdc877 --- /dev/null +++ b/bindings/StartTone.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. + +/** + * The start-tone cue for a [`StartProcedure`] (heat-lifecycle Slice 2) — stored config the console + * uses to play the go-tone (the audio UX lands in Slice 3). Kept minimal and additive. + */ +export type StartTone = { +/** + * The tone frequency in hertz (e.g. `880`). Absent fields default in the console. + */ +hz?: number, +/** + * The tone duration in milliseconds (e.g. `400`). + */ +ms?: number, }; 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/bindings/SubscribeRequest.ts b/bindings/SubscribeRequest.ts new file mode 100644 index 0000000..af388a4 --- /dev/null +++ b/bindings/SubscribeRequest.ts @@ -0,0 +1,47 @@ +// 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"; + +/** + * 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. + * 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 = { +/** + * The resource this subscription is scoped to. + */ +scope: Scope, +/** + * The cursor to resume after, or `None` to start fresh from the snapshot. + */ +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/bindings/Timer.ts b/bindings/Timer.ts new file mode 100644 index 0000000..2058330 --- /dev/null +++ b/bindings/Timer.ts @@ -0,0 +1,59 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChannelCapability } from "./ChannelCapability"; +import type { PluginPresence } from "./PluginPresence"; +import type { TimerId } from "./TimerId"; +import type { TimerKind } from "./TimerKind"; +import type { TimerStatus } from "./TimerStatus"; + +/** + * One configured timer in the application-level registry (issue #73). + * + * The wire shape `GET /timers` returns and the on-disk shape `timers.json` persists: a stable + * [`TimerId`], a human display `name`, the [`TimerKind`] (its config), and a derived + * [`TimerStatus`]. Derives serde (its JSON *is* both the wire and the persisted form) and + * `ts_rs::TS` so the frontend reads a generated `Timer` type. + */ +export type Timer = { +/** + * The stable handle an event selects by and the API addresses (`PUT /timers/{id}`). + */ +id: TimerId, +/** + * The human-readable display name (display-only; the id is authoritative). + */ +name: string, +/** + * The kind + config: a [`TimerKind::Mock`] or a reserved [`TimerKind::Rotorhazard`]. + */ +kind: TimerKind, +/** + * The derived usability of the timer (see [`TimerStatus`]). + */ +status: TimerStatus, +/** + * The timer's **channel capability** (race redesign Slice 4a): the set of frequencies it can + * tune to ([`Fixed`](ChannelCapability::Fixed)) or that it tunes freely + * ([`Flexible`](ChannelCapability::Flexible)). Additive — defaults to + * [`Flexible`](ChannelCapability::Flexible) for a pre-channel-model timer. + */ +channel_capability: ChannelCapability, +/** + * How many nodes/slots the timer has (race redesign Slice 4a) — the **heat-size cap**: a + * heat's lineup must be ≤ this. Additive; defaults to [`DEFAULT_NODE_COUNT`]. + */ +node_count: number, +/** + * The timer's **defined available channels** (race redesign Slice 4a): the raw-MHz channels, + * within its [`channel_capability`](Timer::channel_capability), that the Race Director has + * made available on this timer — the pool per-heat assignment allocates from, in preference + * order. Empty means none configured (assignment then allocates nothing). Additive — defaults + * empty for a pre-channel-model timer. + */ +available_channels: Array, +/** + * Whether this (RotorHazard) timer carries the **GridFPV plugin** (D16, S1). `None` until + * probed (Mock timers, or an RH not yet connected). Live, in-memory, not persisted — reset to + * `None` on load/reconfigure and driven by the connect-time handshake probe. Additive on the + * wire (`#[ts(optional)]`): an older console (or a test fixture) omits it. + */ +plugin?: PluginPresence, }; diff --git a/bindings/TimerId.ts b/bindings/TimerId.ts new file mode 100644 index 0000000..ab56d88 --- /dev/null +++ b/bindings/TimerId.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 a **timer** in the application-level registry (issue #73). + * + * A transparent string newtype like [`EventId`](crate::scope::EventId): the built-in Mock + * has the reserved id [`MOCK_TIMER_ID`]; created timers get an auto-generated slug + suffix id, + * never user-entered (names are display-only). + */ +export type TimerId = string; diff --git a/bindings/TimerKind.ts b/bindings/TimerKind.ts new file mode 100644 index 0000000..df2de10 --- /dev/null +++ b/bindings/TimerKind.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. + +/** + * The kind of a timer — *how* it produces passes (issue #73). + * + * Externally tagged so it maps to a TS discriminated union. [`Mock`](TimerKind::Mock) is the + * synthetic source wired end-to-end in this slice; [`Rotorhazard`](TimerKind::Rotorhazard) is + * **reserved / config-only** — its `url` is stored and round-trips on the wire and on disk, but + * nothing connects to it here (that is 2b / #65). A selected RotorHazard timer is a no-op stub. + */ +export type TimerKind = { "Mock": { +/** + * Laps each sim pilot flies beyond the holeshot. + */ +laps: number, +/** + * The nominal real-time pace of one sim lap, in milliseconds. + */ +lap_ms: number, } } | { "Rotorhazard": { +/** + * The RotorHazard server base URL (e.g. `http://rotorhazard.local:5000`). + */ +url: string, } }; diff --git a/bindings/TimerStatus.ts b/bindings/TimerStatus.ts new file mode 100644 index 0000000..8d0af2a --- /dev/null +++ b/bindings/TimerStatus.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. + +/** + * Whether a timer is currently usable, and — for a live source — the state of its connection + * (issues #73, #65). + * + * Two **static** states describe a timer's resting config: the Mock is always + * [`Ready`](TimerStatus::Ready) (it needs nothing external), and a configured-but-not-yet-dialed + * RotorHazard timer reports [`Configured`](TimerStatus::Configured). The remaining four are + * **dynamic** connection states the Director drives on a live (`live`-feature) RotorHazard timer + * as its connection comes and goes: [`Connecting`](TimerStatus::Connecting) while the socket is + * being established, [`Connected`](TimerStatus::Connected) once it is up, + * [`Disconnected`](TimerStatus::Disconnected) when it drops, and [`Error`](TimerStatus::Error) + * when the connection attempt fails. They are **additive on the wire** — a console that only knows + * `Ready`/`Configured` still parses the type; new variants surface richer status. + * + * These dynamic states are **not persisted** (`timers.json` always restores a timer's resting + * status from its kind — see [`Timer::status_for`]); they are live, in-memory, and reset to + * `Configured` whenever the RH timer is reconfigured. + */ +export type TimerStatus = "Ready" | "Configured" | "Connecting" | "Connected" | "Disconnected" | "Error"; diff --git a/bindings/UpdateClassRequest.ts b/bindings/UpdateClassRequest.ts new file mode 100644 index 0000000..5b55923 --- /dev/null +++ b/bindings/UpdateClassRequest.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 { ClassSource } from "./ClassSource"; +import type { OptionalEdit } from "./OptionalEdit"; + +/** + * The body of `PUT /classes/{id}` — the editable fields of a class (issue #84). + * + * Every field is optional so a partial edit is a one-field body; the id is fixed (it is in the + * path). A present `name` replaces it (a blank one is ignored — the name is required and never + * cleared). A present `source` replaces the provenance. Each optional-metadata field is a + * three-state [`OptionalEdit`]: **absent** leaves it unchanged ([`Keep`](OptionalEdit::Keep)), + * present **`null`** clears it ([`Clear`](OptionalEdit::Clear)), and present **with a value** sets + * it ([`Set`](OptionalEdit::Set)) — exactly the `UpdatePilotRequest` semantics. + */ +export type UpdateClassRequest = { +/** + * A new name, or `None`/blank to leave it unchanged. + */ +name?: string, +/** + * A new provenance, or `None` to leave it unchanged. + */ +source?: ClassSource, +/** + * A new reference id: present value → set, present `null` → clear, absent → leave unchanged. + */ +reference?: OptionalEdit, +/** + * A new description (set / clear / leave-unchanged, like [`reference`](Self::reference)). + */ +description?: OptionalEdit, }; diff --git a/bindings/UpdatePilotRequest.ts b/bindings/UpdatePilotRequest.ts new file mode 100644 index 0000000..d61e453 --- /dev/null +++ b/bindings/UpdatePilotRequest.ts @@ -0,0 +1,56 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { OptionalEdit } from "./OptionalEdit"; +import type { VtxType } from "./VtxType"; + +/** + * The body of `PUT /pilots/{id}` — the editable fields of a pilot (issue #74). + * + * Every field is optional so a partial edit is a one-field body; the id is fixed (it is in the + * path). A present `callsign` replaces it (a blank one is ignored — the callsign is required and + * never cleared). Each optional-metadata field is a three-state [`OptionalEdit`]: **absent** leaves + * it unchanged ([`Keep`](OptionalEdit::Keep)), present **`null`** clears it + * ([`Clear`](OptionalEdit::Clear)), and present **with a value** sets it ([`Set`](OptionalEdit::Set)). + * The wire-level distinction between "field absent" and "field present and `null`" is what lets a + * caller both leave-alone and clear — `#[serde(default)]` on each field maps an absent field to + * `Keep` while a present `null`/value runs [`OptionalEdit`]'s deserializer. + */ +export type UpdatePilotRequest = { +/** + * A new callsign, or `None`/blank to leave it unchanged. + */ +callsign?: string, +/** + * A new real name: present value → set, present `null` → clear, absent → leave unchanged. + */ +name?: OptionalEdit, +/** + * A new pronunciation hint (set / clear / leave-unchanged, like [`name`](Self::name)). + */ +phonetic?: OptionalEdit, +/** + * A new team / club (set / clear / leave-unchanged). + */ +team?: OptionalEdit, +/** + * A new hex color `#RRGGBB` (set / clear / leave-unchanged; a set value is validated). + */ +color?: OptionalEdit, +/** + * A new ISO 3166-1 alpha-2 country code (set / clear / leave-unchanged; a set value is + * validated and normalized uppercase). + */ +country?: OptionalEdit, +/** + * A **full replacement** of the VTX set when present (absent leaves it unchanged; present `[]` + * clears it) — a present array replaces the stored set wholesale (deduped, stable order), + * rather than a per-value set/clear edit. + */ +vtx_types?: Array, +/** + * A new MultiGP id (set / clear / leave-unchanged). + */ +multigp_id?: OptionalEdit, +/** + * A new Velocidrone id (set / clear / leave-unchanged). + */ +velocidrone_id?: OptionalEdit, }; diff --git a/bindings/UpdateRoundReq.ts b/bindings/UpdateRoundReq.ts new file mode 100644 index 0000000..2f6e0e7 --- /dev/null +++ b/bindings/UpdateRoundReq.ts @@ -0,0 +1,74 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChannelMode } from "./ChannelMode"; +import type { ClassId } from "./ClassId"; +import type { GraceWindow } from "./GraceWindow"; +import type { ProtestWindow } from "./ProtestWindow"; +import type { SeedingRule } from "./SeedingRule"; +import type { StartProcedure } from "./StartProcedure"; +import type { WinCondition } from "./WinCondition"; + +/** + * The body of `PUT /events/{id}/rounds/{round}` — the editable fields of an existing round (race + * redesign Slice 2a). The round's [`id`](RoundDef::id) is the path segment and is **not** editable; + * every other field is replaced wholesale. Same validation as the add path. + */ +export type UpdateRoundReq = { +/** + * The new display label. + */ +label: string, +/** + * The new eligible classes. Each must be one of the event's selected classes. + */ +classes: Array, +/** + * The new format — a known [`FormatRegistry`] name. + */ +format: string, +/** + * The new format config knobs, stored verbatim. + */ +params: { [key in string]: string }, +/** + * The new win condition. **Optional** (open-practice refinement): omit it to store the inert + * [`default_win_condition`] (an open-practice round does no scoring). + */ +win_condition?: WinCondition, +/** + * The new seeding rule; defaults to [`SeedingRule::FromRoster`] when omitted. + */ +seeding: SeedingRule, +/** + * The new practice duration in seconds (open-practice refinement). Optional — omit for **no + * time limit**. Stored on [`RoundDef::time_limit_secs`]. + */ +time_limit_secs?: number, +/** + * The new channel mode (race redesign Slice 7a). Optional — **omit it** to take the format's + * default ([`ChannelMode::default_for_format`]); supply it to override. + */ +channel_mode?: ChannelMode, +/** + * The new staging timer in seconds (heat-lifecycle Slice 2). Optional — omit for the + * [`default_staging_timer_secs`] (300). + */ +staging_timer_secs?: number, +/** + * The new start procedure (heat-lifecycle Slice 2). Optional — omit for the + * [`StartProcedure::default`]. + */ +start_procedure?: StartProcedure, +/** + * The new grace window (heat-lifecycle Slice 2). Optional — omit for the + * [`default_grace_window`]. + */ +grace_window?: GraceWindow, +/** + * The new protest window (marshaling Slice 5). Optional — omit for the default + * [`ProtestWindow::Off`] (manual finalize only). + */ +protest_window?: ProtestWindow, +/** + * Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. + */ +min_lap_secs?: number, }; diff --git a/bindings/UpdateTimerRequest.ts b/bindings/UpdateTimerRequest.ts new file mode 100644 index 0000000..97754e1 --- /dev/null +++ b/bindings/UpdateTimerRequest.ts @@ -0,0 +1,33 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChannelCapability } from "./ChannelCapability"; +import type { TimerKind } from "./TimerKind"; + +/** + * The body of `PUT /timers/{id}` — the editable fields of a timer (issue #73). + * + * Edits the display `name` and/or the [`TimerKind`] config (e.g. retune the sim's `lap_ms`, or + * point a RotorHazard timer at a new URL). Both optional so a partial edit is a one-field body; + * the id is fixed (it is in the path) and the built-in Mock may be retuned but not removed. + */ +export type UpdateTimerRequest = { +/** + * A new display name, or `None` to leave it unchanged. + */ +name?: string, +/** + * A new kind + config, or `None` to leave it unchanged. + */ +kind?: TimerKind, +/** + * A new **channel capability** (race redesign Slice 4a), or `None` to leave it unchanged. + */ +channel_capability?: ChannelCapability, +/** + * A new **node/slot count** (race redesign Slice 4a), or `None` to leave it unchanged. + */ +node_count?: number, +/** + * A new **available-channels** set in raw MHz (race redesign Slice 4a), or `None` to leave it + * unchanged. + */ +available_channels?: Array, }; diff --git a/bindings/VoidReason.ts b/bindings/VoidReason.ts new file mode 100644 index 0000000..38f5eb3 --- /dev/null +++ b/bindings/VoidReason.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Why a pass sits on the removal record instead of the lap chain. + */ +export type VoidReason = "Marshal" | "UnderMinLap"; diff --git a/bindings/VoidedPass.ts b/bindings/VoidedPass.ts new file mode 100644 index 0000000..4255d19 --- /dev/null +++ b/bindings/VoidedPass.ts @@ -0,0 +1,32 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LogRef } from "./LogRef"; +import type { SourceTime } from "./SourceTime"; +import type { VoidReason } from "./VoidReason"; + +/** + * One RD-voided gate pass, as the lap list records it (see [`CompetitorLaps::voided`]). + */ +export type VoidedPass = { +/** + * Source-clock time (µs) of the voided pass — its RAW instant (no re-time applied): the + * removal record exists so re-detection can recognise the crossing on the trace, and the + * trace knows nothing of a re-time. + */ +at: SourceTime, +/** + * The voided pass's own global log offset (a stable row identity for the UI). + */ +pass_ref: LogRef, +/** + * The **standing void event's** offset — the target a RESTORE (void-the-void) addresses. + * For an AUTO-suppressed pass (see [`VoidReason::UnderMinLap`]) this is the pass's own + * offset: there is no void event, and the restore path is a marshal ruling on the pass + * itself (an [`Event::LapAdjusted`] re-asserting its raw instant — an explicit ruling + * always outranks the floor). + */ +void_ref: LogRef, +/** + * WHY the pass is off the lap chain — the console labels the row (and picks the restore + * command) by this. + */ +reason: VoidReason, }; diff --git a/bindings/VtxType.ts b/bindings/VtxType.ts new file mode 100644 index 0000000..63bf6f4 --- /dev/null +++ b/bindings/VtxType.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. + +/** + * The kind of **video transmitter** a pilot flies (issue #74). + * + * A small closed enum the directory records so the RD can group / display pilots by their video + * system. Externally tagged (the default serde enum representation) so it maps to a TS string + * union (`"Analog" | "HDZero" | "DJI" | "Walksnail" | "Other"`). A pilot carries a **set** of + * these (see [`Pilot::vtx_types`]) since many pilots run more than one video system; the + * [`Other`](VtxType::Other) variant is the catch-all for anything not enumerated. + */ +export type VtxType = "Analog" | "HDZero" | "DJI" | "Walksnail" | "Other"; diff --git a/bindings/WinCondition.ts b/bindings/WinCondition.ts new file mode 100644 index 0000000..f79dd69 --- /dev/null +++ b/bindings/WinCondition.ts @@ -0,0 +1,20 @@ +// 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. + * Renders as a plain TS `number` (bounded far below 2^53). + */ +window_micros: number, } } | { "FirstToLaps": { +/** + * The target lap count. + */ +n: number, } } | "BestLap" | { "BestConsecutive": { +/** + * How many consecutive laps the window spans. + */ +n: number, } }; diff --git a/crates/adapters/Cargo.toml b/crates/adapters/Cargo.toml index 675475a..c591880 100644 --- a/crates/adapters/Cargo.toml +++ b/crates/adapters/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gridfpv-adapters" -version = "0.1.0" +version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true @@ -12,7 +12,11 @@ rust-version.workspace = true # crate stays dependency-light; the live integration tests (`tests/rh_live.rs` # against dockerized RotorHazard, `tests/velocidrone_ws.rs` against an in-process # mock WebSocket server) enable it. -live = ["dep:rust_socketio", "dep:tungstenite"] +# +# `live` also pulls `native-tls` with `vendored` so the TLS backend is self-contained on +# Linux (see the dependency note below) — a `--features live` AppImage carries no system +# libssl dependency. +live = ["dep:rust_socketio", "dep:tungstenite", "dep:native-tls"] [dependencies] gridfpv-events.workspace = true @@ -20,6 +24,13 @@ serde.workspace = true serde_json.workspace = true rust_socketio = { version = "0.6", optional = true } tungstenite = { version = "0.29", optional = true } +# The TLS backend behind the live transports' `wss://` support. `native-tls` selects the +# OS-native stack per platform — SChannel on Windows, Secure Transport on macOS, OpenSSL on +# Linux. The `vendored` feature statically compiles OpenSSL *into the binary* on Linux, so a +# `--features live` build (the AppImage) is self-contained and pulls no system libssl; it is a +# no-op on Windows/macOS, which use the OS-provided TLS. Declared here (not just transitively via +# rust_socketio) so we can pin `vendored` on; `live` enables it. +native-tls = { version = "0.2", features = ["vendored"], optional = true } [dev-dependencies] gridfpv-projection.workspace = true diff --git a/crates/adapters/src/clock.rs b/crates/adapters/src/clock.rs index ac85ba2..18c3818 100644 --- a/crates/adapters/src/clock.rs +++ b/crates/adapters/src/clock.rs @@ -186,6 +186,7 @@ mod tests { sequence, gate: GateIndex::LAP, signal: None, + heat: None, } } diff --git a/crates/adapters/src/dedup.rs b/crates/adapters/src/dedup.rs index 80a468c..ac7d70c 100644 --- a/crates/adapters/src/dedup.rs +++ b/crates/adapters/src/dedup.rs @@ -172,6 +172,7 @@ mod tests { sequence, gate: GateIndex::LAP, signal: None, + heat: None, } } diff --git a/crates/adapters/src/lib.rs b/crates/adapters/src/lib.rs index c7e3287..ba00be7 100644 --- a/crates/adapters/src/lib.rs +++ b/crates/adapters/src/lib.rs @@ -202,6 +202,7 @@ mod tests { sequence: None, gate: GateIndex::LAP, signal: None, + heat: None, })] } } diff --git a/crates/adapters/src/rotorhazard.rs b/crates/adapters/src/rotorhazard.rs index db867ef..87a5acf 100644 --- a/crates/adapters/src/rotorhazard.rs +++ b/crates/adapters/src/rotorhazard.rs @@ -34,13 +34,15 @@ //! - **`current_laps`** — [`Raw::CurrentLaps`]. A **full snapshot**, re-sent on every //! update: `{ "current": { "node_index": [ { laps: [ … ], pilot, … }, … ] } }`. //! The **outer array index is the node index**. Each lap carries `lap_index`, -//! `lap_number`, `lap_raw` (ms), `lap_time` (a `"M:SS.mmm"` *string*), -//! `lap_time_stamp` (cumulative ms since race start, float), `splits` and -//! `late_lap`. There is **no** `source`, **no** `deleted`, **no** per-lap -//! `peak_rssi`, and **no** per-lap `node_index` — deleted laps are filtered out -//! server-side before the snapshot is built. The adapter diffs each snapshot -//! against what it has already emitted per node and emits a [`Pass`] only for the -//! *new* laps. +//! `lap_number`, `lap_time_stamp` (cumulative ms since race start, float), `splits` +//! and `late_lap`. **The lap-time / deletion shape changed across RH versions:** on +//! RH ≤ 4.0 the duration was `lap_raw` (ms) with `lap_time` a `"M:SS.mmm"` *string* +//! and deleted laps pre-filtered server-side; on **RH 4.3+/4.4** `lap_time` is a +//! *numeric* ms duration (the pretty string moved to `lap_time_formatted`) and laps +//! now carry `source` and `deleted` inline. The adapter only reads `lap_number` and +//! `lap_time_stamp` (both stable); it parses `lap_time` permissively (string *or* +//! number) and **skips `deleted` laps**. It diffs each snapshot against what it has +//! already emitted per node and emits a [`Pass`] only for the *new* laps. //! - **`pass_record`** — [`Raw::PassRecord`]. Fires once per crossing: //! `{ node, frequency, timestamp }` where `timestamp` is epoch-milliseconds. This //! is a real-time *cross-check* signal (it confirms a crossing happened on a node); @@ -80,7 +82,8 @@ //! the single source of truth for passes. use gridfpv_events::{ - AdapterId, CompetitorRef, Event, GateIndex, Pass, SessionId, SignalContext, SourceTime, + AdapterId, CompetitorRef, Event, GateIndex, Pass, SessionId, SignalChunk, SignalContext, + SignalHistory, SignalThresholds, SourceTime, }; use serde::{Deserialize, Serialize}; @@ -128,8 +131,52 @@ pub enum Raw { /// events (see module docs). PassRecord(RawPassRecord), /// A `node_data` message carrying per-node `pass_peak_rssi[]`. Updates the RSSI - /// cache used to annotate subsequent passes. + /// cache used to annotate subsequent passes, and (for a signal-capable adapter while a + /// race is active) emits a [`Event::SignalChunk`] trace sample per node. NodeData(RawNodeData), + /// An `enter_and_exit_at_levels` message carrying the per-node detection thresholds. + /// Emits [`Event::SignalThresholds`] for a signal-capable adapter. + EnterExitLevels(RawEnterExitLevels), + /// A `current_marshal_data` response (`RHUI.emit_race_marshal_data`), requested at heat end on + /// **newer** RotorHazard. Carries the **dense** per-node `history_values`/`history_times` trace + /// for every seat at once; emits one [`Event::SignalHistory`] per node for a signal-capable + /// adapter (the full-fidelity trace that supersedes the coarse streamed [`SignalChunk`]s — see + /// [`RawMarshalData`]). + MarshalData(RawMarshalData), + /// A `race_list` response (`RHUI.emit_race_list`), listing the **saved** races and their + /// per-pilot `pilotrace_id`s. On the RotorHazard build whose marshal API is per-pilotrace + /// ([`Raw::RaceDetails`]), the transport reads these ids to pull each seat's dense history. + /// Emits no canonical events itself (it is a transport routing payload); the adapter exposes the + /// ids via [`take_pilotrace_requests`](RotorHazardAdapter::take_pilotrace_requests). + RaceList(RawRaceList), + /// A `race_details` response (`get_pilotrace`), the **per-pilotrace** dense marshal payload on + /// the RotorHazard build that has no aggregate `current_marshal_data`. Carries one seat's + /// `history_values`/`history_times` + `enter_at`/`exit_at`; emits a [`Event::SignalHistory`] + /// (and refreshes [`SignalThresholds`]) for that seat — see [`RawRaceDetails`]. + RaceDetails(RawRaceDetails), + /// A `heat_data` response (`RHUI.emit_heat_data`), the list of configured heats with their ids. + /// Emits no canonical events; the adapter records the heat ids so the transport can **select a + /// savable heat** before staging (a heat must be current for RotorHazard to persist the run's + /// dense history — `on_save_laps`/`emit_race_marshal_data` no-op while `current_heat` is None in + /// the default practice mode). Exposed via [`take_heat_ids`](RotorHazardAdapter::take_heat_ids). + HeatData(RawHeatData), + /// A `pilot_data` response (`RHUI.emit_pilot_data`), the configured pilots with their ids. Emits + /// no canonical events; the adapter records the ids so the transport can learn the id of a pilot + /// it just created (`add_pilot`) — the newest (highest) id — to then assign it onto a heat seat + /// when **seating** a heat's bound pilots before racing (the laps-attribute fix). Exposed via + /// [`take_pilot_ids`](RotorHazardAdapter::take_pilot_ids). + PilotData(RawPilotData), + /// A `gridfpv_signal` broadcast from the **GridFPV RH plugin** (D16, Slice 2): live per-node + /// signal pushed in-process — `current_rssi`, the enter/exit detection levels, and the dense + /// `history_values`/`history_times` window — plus the race-start clock origin. The adapter folds + /// it into the same canonical [`SignalThresholds`]/[`SignalHistory`] facts the post-race + /// save-then-pull produced, but **live**, and (once seen) suppresses that pull entirely. Absent on + /// a stock RH (no plugin), so the socket fallback still pulls. See [`RawGridSignal`]. + GridSignal(RawGridSignal), + /// A `gridfpv_pass` broadcast from the GridFPV RH plugin (D16, Slice 3): the per-node pass atom + /// emitted natively from `RACE_LAP_RECORDED`. Folds to a [`Pass`], deduped against the + /// `current_laps` snapshot path on the per-node `lap_number`. See [`RawGridPass`]. + GridPass(RawGridPass), } /// A RotorHazard `race_status` message (see [`Raw::RaceStatus`]). @@ -192,17 +239,28 @@ pub struct RawLap { pub lap_number: u64, /// The lap duration in milliseconds (RotorHazard `lap_raw`). Advisory only — the /// engine derives laps from the pass stream — so it is carried for reference. + /// Present on RH ≤ 4.0; **renamed to a numeric `lap_time`** on RH 4.3+/4.4 (see + /// `lap_time` below), so this is `None` against current RotorHazard. #[serde(default)] pub lap_raw: Option, - /// RotorHazard's pretty `"M:SS.mmm"` lap-time **string**. Advisory. + /// RotorHazard's lap-time field. **Wire-shape changed across RH versions:** a pretty + /// `"M:SS.mmm"` *string* on RH ≤ 4.0, a *numeric* duration in ms on RH 4.3+/4.4 + /// (where the pretty string moved to `lap_time_formatted`). Advisory and unused — we + /// type it as a permissive [`serde_json::Value`] so either shape (or its absence) + /// deserializes cleanly. Lap timing is derived from `lap_time_stamp`, not this. #[serde(default)] - pub lap_time: Option, + pub lap_time: Option, /// Crossing time in **cumulative milliseconds since race start** (RotorHazard /// `lap_time_stamp`, a float). Converted to microseconds for [`SourceTime`]. pub lap_time_stamp: f64, /// Whether RotorHazard flagged this as a late lap (over the time limit). Advisory. #[serde(default)] pub late_lap: bool, + /// Whether RotorHazard has **deleted** this lap. Absent on RH ≤ 4.0 (deleted laps + /// were pre-filtered server-side → `None`); present on RH 4.3+/4.4, which may carry + /// deleted laps inline. We skip `Some(true)` laps so a deletion never mints a pass. + #[serde(default)] + pub deleted: Option, } /// A RotorHazard `pass_record` (see [`Raw::PassRecord`]). Advisory cross-check only. @@ -219,12 +277,294 @@ pub struct RawPassRecord { } /// A RotorHazard `node_data` message (see [`Raw::NodeData`]). Per-node RSSI arrays. +/// +/// RotorHazard's `emit_node_data` (`RHUI.py`) re-sends these scalar arrays on its heartbeat +/// cadence while a race runs — they are the **latest aggregate** per node, *not* a per-tick +/// history array (the full `history_values` trace lives in the request-driven +/// `current_marshal_data`, which a live translator does not subscribe to). So the trace this +/// adapter captures samples `node_peak_rssi` at the `node_data` emit cadence — see +/// [`SignalChunk`](gridfpv_events::SignalChunk)'s fidelity bound. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RawNodeData { /// Per-node peak RSSI of the most recent pass (array index = node index). This is - /// the per-pass RSSI source; `0` under mock nodes. + /// the per-pass RSSI source for a [`Pass`]'s [`SignalContext`]; `0` under mock nodes. #[serde(default)] pub pass_peak_rssi: Vec, + /// Per-node **current** peak RSSI (the node's running signal level, reset each crossing; + /// array index = node index). This is the per-tick value the captured trace samples — it + /// is the closest live proxy to "current RSSI" that `node_data` exposes. Absent on older + /// payloads (defaults empty), in which case the trace falls back to `pass_peak_rssi`. + #[serde(default)] + pub node_peak_rssi: Vec, +} + +/// A RotorHazard `enter_and_exit_at_levels` message (`RHUI.emit_enter_and_exit_at_levels`): +/// the per-node detection thresholds the timer is calibrated with. The signal-as-evidence +/// layer captures these so a marshal sees the levels a call was made against. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawEnterExitLevels { + /// Per-node enter threshold (RSSI rising above this opens a pass; array index = node index). + #[serde(default)] + pub enter_at_levels: Vec, + /// Per-node exit threshold (RSSI falling below this closes the pass; array index = node index). + #[serde(default)] + pub exit_at_levels: Vec, +} + +/// A RotorHazard `current_marshal_data` response (`RHUI.emit_race_marshal_data`), the +/// **request-driven** dense marshal payload its own marshal page pulls *after* a race. +/// +/// Shape (validated against `src/server/RHUI.py::emit_race_marshal_data`): +/// `{ "race": { "start_time": , … }, "seats": { "": { history_values, +/// history_times, enter_at, exit_at, laps, … }, … } }`. The `seats` map is keyed by **stringified +/// node index** (JSON object keys are strings). Each seat carries the detector's own per-tick +/// `history_values` (RSSI integers) paired with `history_times` (monotonic-clock **seconds**, +/// floats), the dense trace RotorHazard's marshal graph renders. `race.start_time` is the +/// monotonic-second origin of the race; subtracting it makes each `history_times` value +/// race-relative — the same origin as `lap_time_stamp` / [`SourceTime`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawMarshalData { + /// The race-level header. Carries `start_time` (the monotonic-second origin) used to make the + /// per-seat `history_times` race-relative. Optional: absent on a payload built before a race. + #[serde(default)] + pub race: Option, + /// Per-seat dense data, keyed by **stringified node index** (`"0"`, `"1"`, …). + #[serde(default)] + pub seats: std::collections::BTreeMap, +} + +/// The `race` header of a `current_marshal_data` payload (see [`RawMarshalData`]). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawMarshalRace { + /// The race's monotonic-clock start time in **seconds** (`race.start_time_monotonic`). The + /// origin the per-seat `history_times` are measured from; subtracting it yields race-relative + /// time. Absent on some payloads (defaults to `0.0`, i.e. times already race-relative). + #[serde(default)] + pub start_time: f64, +} + +/// One seat's dense marshal data within a `current_marshal_data` payload (see [`RawMarshalData`]). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawMarshalSeat { + /// The detector's per-tick RSSI history (filtered ADC counts), parallel to `history_times`. + /// Accepts either a JSON array or a JSON-encoded string (different RH builds wire it either way). + #[serde(default, deserialize_with = "de_f64_history")] + pub history_values: Vec, + /// The monotonic-clock **seconds** timestamp of each `history_values` sample, parallel to it. + #[serde(default, deserialize_with = "de_f64_history")] + pub history_times: Vec, +} + +/// A `race_list` response (`RHUI.emit_race_list`): the saved-race tree whose leaves carry the +/// `pilotrace_id`s the per-pilotrace marshal request ([`Raw::RaceDetails`]) needs. +/// +/// Shape: `{ "heats": { "": { "rounds": { "": { "start_time": , +/// "pilotraces": [ { "pilotrace_id", "node_index" }, … ] }, … } }, … } }`. The adapter walks it for +/// the `(pilotrace_id, node_index)` pairs the transport then pulls one at a time, carrying the +/// round's `start_time` so the per-pilotrace history can be made race-relative. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawRaceList { + /// Saved heats by stringified heat id. + #[serde(default)] + pub heats: std::collections::BTreeMap, +} + +/// One heat in a `race_list` (see [`RawRaceList`]). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawRaceListHeat { + /// Saved rounds by stringified round id. + #[serde(default)] + pub rounds: std::collections::BTreeMap, +} + +/// One saved round in a `race_list` (see [`RawRaceList`]). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawRaceListRound { + /// The round's monotonic-clock start time in **seconds** — the origin the per-pilotrace + /// `history_times` are measured from (used to make the dense history race-relative). + #[serde(default)] + pub start_time: f64, + /// The per-pilot saved-race entries, each with the `pilotrace_id` the marshal request targets. + #[serde(default)] + pub pilotraces: Vec, +} + +/// One per-pilot saved-race entry in a `race_list` round (see [`RawRaceListRound`]). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawRaceListPilotRace { + /// The id `get_pilotrace` targets to fetch this seat's dense history. + pub pilotrace_id: i64, + /// The node seat this saved pilotrace belongs to (maps to `node-{index}`). + #[serde(default)] + pub node_index: Option, +} + +/// A `race_details` response (`get_pilotrace`): one saved pilotrace's dense history + thresholds. +/// +/// Shape: `{ "node_index", "history_values", "history_times", "enter_at", "exit_at", … }`. The +/// history arrays wire as **JSON-encoded strings** on this RH build (`json.dumps(...)`), so they are +/// parsed leniently. `history_times` are monotonic **seconds**; the adapter subtracts the round's +/// `start_time` (carried from the `race_list`) — or the first sample when no start is known — to make +/// the dense trace race-relative. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawRaceDetails { + /// The node seat this pilotrace belongs to (maps to `node-{index}`). + #[serde(default)] + pub node_index: Option, + /// The detector's per-tick RSSI history (filtered ADC counts), parallel to `history_times`. + #[serde(default, deserialize_with = "de_f64_history")] + pub history_values: Vec, + /// The monotonic-clock **seconds** timestamp of each `history_values` sample, parallel to it. + #[serde(default, deserialize_with = "de_f64_history")] + pub history_times: Vec, + /// The node's enter threshold the call was made against, if reported. + #[serde(default)] + pub enter_at: Option, + /// The node's exit threshold the call was made against, if reported. + #[serde(default)] + pub exit_at: Option, +} + +/// A RotorHazard `heat_data` response (`RHUI.emit_heat_data`): the configured heats. +/// +/// Shape: `{ "heats": [ { "id": , "slots": [ { "id", "node_index", … }, … ], … }, … ] }`. +/// The transport pulls this (via `load_data { heat_data }`) so it can select a **savable** current +/// heat before staging — RH only persists a run's dense history for a saved heat +/// (`current_heat != HEAT_ID_NONE`) — and, when **seating** a heat's bound pilots, learn each +/// node's **slot id** (the `HeatNode` primary key `alter_heat` targets to assign a pilot to a node). +/// The adapter records each heat's id and its node→slot map; the rest is ignored here. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawHeatData { + /// The configured heats; each heat's `id` and per-node `slots` are read. + #[serde(default)] + pub heats: Vec, +} + +/// One heat in a `heat_data` response (see [`RawHeatData`]). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawHeat { + /// The heat's id, used to select it as the current (savable) heat. + pub id: i64, + /// The heat's seats — one [`RawHeatSlot`] per node — used to assign a pilot to a node + /// (`alter_heat { heat, slot_id, pilot }`) when seating the heat's bound pilots before racing. + #[serde(default)] + pub slots: Vec, +} + +/// One seat (RotorHazard `HeatNode`) of a heat in a `heat_data` response (see [`RawHeat`]). +/// +/// `alter_heat` assigns a pilot to a heat by **slot id** (the `HeatNode` primary key), not by node +/// index — so seating the heat's bound pilots reads each seat's `(node_index, id)` here and emits +/// `alter_heat { heat, slot_id: id, pilot }` for the seat at the bound node index. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawHeatSlot { + /// The slot's id (`HeatNode` primary key) — the `slot_id` `alter_heat` targets. + pub id: i64, + /// The node index this slot seats a pilot on (0-based). `None` for an unprogrammed slot. + #[serde(default)] + pub node_index: Option, +} + +/// A RotorHazard `pilot_data` response (`RHUI.emit_pilot_data`): the configured pilots. +/// +/// Shape: `{ "pilots": [ { "pilot_id": , … }, … ] }`. The transport pulls this after creating a +/// pilot (`add_pilot`) so it can learn the new pilot's id — the **highest** id, the one just added — +/// to assign onto a heat seat when seating a heat's bound pilots. The rest of each pilot object is +/// ignored here. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawPilotData { + /// The configured pilots; only each pilot's `pilot_id` is read. + #[serde(default)] + pub pilots: Vec, +} + +/// One pilot in a `pilot_data` response (see [`RawPilotData`]). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawPilotEntry { + /// The pilot's id (`Pilot` primary key), used to assign the pilot onto a heat seat. + pub pilot_id: i64, +} + +/// Deserialize a history array that may arrive either as a JSON array (`[1, 2, 3]`) or as a +/// JSON-**encoded string** (`"[1, 2, 3]"`). RotorHazard's `get_pilotrace` `json.dumps`es the history +/// while `current_marshal_data` sends a bare array — accept both so one [`Raw`] shape covers both RH +/// builds. A malformed string yields an empty history (no panic). +fn de_f64_history<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + #[derive(Deserialize)] + #[serde(untagged)] + enum ArrayOrString { + Array(Vec), + Str(String), + } + Ok(match ArrayOrString::deserialize(deserializer)? { + ArrayOrString::Array(v) => v, + ArrayOrString::Str(s) => serde_json::from_str(&s).unwrap_or_default(), + }) +} + +/// A `gridfpv_signal` broadcast from the GridFPV RH plugin (see [`Raw::GridSignal`]). +/// +/// Live per-node signal pushed in-process while a race runs. `race_start` is RotorHazard's +/// monotonic race origin in **seconds** (the same clock as each node's `history_times`), used to make +/// the dense history race-relative — exactly as the marshal-data path does. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawGridSignal { + /// The race-start origin in seconds (RotorHazard monotonic). `None` if no race is running. + #[serde(default)] + pub race_start: Option, + /// Per-node signal snapshots. + #[serde(default)] + pub nodes: Vec, +} + +/// One node's entry in a [`RawGridSignal`] broadcast. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawGridSignalNode { + /// Zero-based node/seat index. + pub index: usize, + /// The accumulator index this incremental slice starts at (S2.1): `0` means **replace** (a full + /// snapshot — the first broadcast of a race, or the final flush); a value `== the current + /// accumulated length` means **append** this slice. Anything else is an out-of-sync slice (a + /// missed/duplicated tick) the adapter skips until the next replace. Defaults to `0` (replace). + #[serde(default)] + pub base: usize, + /// The node's current live RSSI (advisory; the live coarse trace still comes from `node_data`). + #[serde(default)] + pub current_rssi: Option, + /// The node's enter detection level (rising past this opens a pass). + #[serde(default)] + pub enter_at: Option, + /// The node's exit detection level (falling below this closes a pass). + #[serde(default)] + pub exit_at: Option, + /// The dense per-sample RSSI history window (parallel to `history_times`). + #[serde(default, deserialize_with = "de_f64_history")] + pub history_values: Vec, + /// The dense per-sample timestamps in seconds (RotorHazard monotonic; parallel to + /// `history_values`). + #[serde(default, deserialize_with = "de_f64_history")] + pub history_times: Vec, +} + +/// A `gridfpv_pass` broadcast from the GridFPV RH plugin (see [`Raw::GridPass`]) — the per-node pass +/// atom the plugin emits natively from `RACE_LAP_RECORDED` (D16, Slice 3), attributed by node seat. +/// Folds to the same canonical [`Pass`] the `current_laps` snapshot does, deduped on the per-node +/// `lap_number`, so the two coexist (whichever arrives first wins; a stock RH has only `current_laps`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawGridPass { + /// Zero-based node/seat index the lap was recorded on. + pub node_index: usize, + /// Per-node monotonic lap counter (`0` is the holeshot) — the pass `sequence` + dedup key. + pub lap_number: u64, + /// Crossing time in cumulative milliseconds since race start (same unit as `current_laps`). + pub lap_time_stamp: f64, + /// The pass's peak RSSI, if RH reported one — becomes the [`Pass`]'s [`SignalContext`]. + #[serde(default)] + pub peak_rssi: Option, } /// The competitor handle for a RotorHazard node seat: `"node-{index}"`. Stable across @@ -261,8 +601,87 @@ pub struct RotorHazardAdapter { /// Suppresses passes already emitted (re-sent snapshot / reconnect). Keyed on the /// pass `(adapter, competitor, sequence)` by [`Deduplicator`]. dedup: Deduplicator, + /// Whether to capture the RSSI trace (marshaling Slice 1). Gated on the adapter's signal + /// capability so only signal-capable sources produce [`SignalChunk`]/[`SignalThresholds`]; + /// `true` for a real RotorHazard. A non-signal source would set this `false` and emit none. + signal_capture: bool, + /// The capture cadence: microseconds between consecutive trace samples, used to place each + /// `node_data` sample on the source clock. RotorHazard's `node_data` is heartbeat-driven, so + /// this mirrors its emit interval ([`DEFAULT_NODE_DATA_PERIOD_MICROS`]). + node_data_period_micros: u32, + /// Whether a race is currently running (between the `SessionStarted` and `SessionEnded` + /// edges). Trace samples are only captured while racing — idle `node_data` is monitoring + /// churn, not evidence for a heat. + race_active: bool, + /// Per-node trace sample counter, reset each race. The `from` of a node's chunk is + /// `index * node_data_period_micros`, so concatenated chunks reconstruct a contiguous trace + /// deterministically without the adapter reading a clock. + sample_index: std::collections::HashMap, + /// Last `(enter, exit)` thresholds emitted per node, so an unchanged + /// `enter_and_exit_at_levels` re-send does not re-emit a [`SignalThresholds`] fact. + last_thresholds: std::collections::HashMap, + /// Set when a race reaches `DONE` (a signal-capable adapter only): a flag the **transport** drains + /// via [`take_marshal_request`](Self::take_marshal_request) to know it should send RotorHazard's + /// `current_race_marshal` request and pull the dense `current_marshal_data` history at heat end. + /// The pure translator cannot speak the socket, so it records the *intent* here and the transport + /// acts on it — keeping all wire knowledge in the transport while the trigger stays in the + /// translator (driven by the same `race_status` stream it already folds). + pending_marshal_request: bool, + /// Whether the GridFPV RH plugin is pushing **live signal** (`gridfpv_signal`, Slice 2). Set on + /// the first such broadcast and kept for the adapter's life (it persists across reconnects, like + /// the dedup). While set, the dense trace arrives live, so the adapter **suppresses the post-race + /// save-then-pull** ([`pending_marshal_request`](Self::pending_marshal_request) is not raised on + /// the DONE edge). A stock RH never sends `gridfpv_signal`, so this stays `false` and the pull + /// remains the fallback. + live_signal_active: bool, + /// Per-node accumulated dense trace (race-relative µs + RSSI), reset each race (S2.1). The plugin + /// streams the dense history **incrementally** (only new samples each tick); the adapter appends + /// them here (or replaces on a full snapshot) and emits the accumulated [`SignalHistory`] when it + /// changes — so the wire stays small while the projection still gets the full trace. + dense_accum: std::collections::HashMap, Vec)>, + /// Pending per-pilotrace marshal pulls discovered from a `race_list`, drained by the transport + /// via [`take_pilotrace_requests`](Self::take_pilotrace_requests). On the RotorHazard build whose + /// marshal API is per-pilotrace (`get_pilotrace` -> `race_details`), the heat-end flow is: + /// save laps -> request `race_list` -> pull each `(pilotrace_id)` here -> fold `race_details` into + /// dense history. Each entry carries the round `start_time` so the history can be made + /// race-relative when the `race_details` response (which omits it) comes back. + pending_pilotrace_requests: Vec, + /// The round `start_time` (monotonic seconds) per `node_index`, learned from the most recent + /// `race_list`, so a `race_details` response (which carries no start time) can be made + /// race-relative. Cleared each new race. + pilotrace_start_time: std::collections::HashMap, + /// Configured RotorHazard heat ids learned from the most recent `heat_data`, drained by the + /// transport via [`take_heat_ids`](Self::take_heat_ids) so it can select a **savable** current + /// heat before staging (RH only persists a run's dense history for a saved heat). Empty until a + /// `heat_data` is folded. + pending_heat_ids: Vec, + /// The per-node **slot ids** of each configured heat, learned from the most recent `heat_data`, + /// drained by the transport via [`take_heat_slots`](Self::take_heat_slots). Seating a heat's + /// bound pilots needs each node's slot id (the `HeatNode` PK `alter_heat` targets). Keyed by heat + /// id → `(node_index → slot_id)`. Empty until a `heat_data` is folded. + pending_heat_slots: std::collections::HashMap>, + /// Configured RotorHazard pilot ids learned from the most recent `pilot_data`, drained by the + /// transport via [`take_pilot_ids`](Self::take_pilot_ids) so it can learn the id of a pilot it + /// just created (`add_pilot` — the highest id) to assign onto a heat seat when seating. Empty + /// until a `pilot_data` is folded. + pending_pilot_ids: Vec, +} + +/// A pending per-pilotrace marshal pull the transport issues (`get_pilotrace { pilotrace_id }`), +/// discovered from a `race_list`. See [`RotorHazardAdapter::take_pilotrace_requests`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PilotRaceRequest { + /// The id to fetch (`get_pilotrace`'s `pilotrace_id`). + pub pilotrace_id: i64, } +/// Default trace-capture cadence: RotorHazard's `node_data` heartbeat emit interval. The real +/// server re-sends `node_data` a few times a second; `100_000` µs (10 Hz) is a representative +/// default. The exact realtime cadence is the fidelity bound to confirm on hardware +/// (marshaling.html §4) — the capture is faithful to *what RH streams*, which is one aggregate +/// sample per emit, not the detector's internal per-tick history. +pub const DEFAULT_NODE_DATA_PERIOD_MICROS: u32 = 100_000; + impl RotorHazardAdapter { /// A RotorHazard adapter with the default id (`"rotorhazard"`). pub fn new() -> Self { @@ -277,9 +696,70 @@ impl RotorHazardAdapter { seen_seats: std::collections::HashSet::new(), pass_peak_rssi: std::collections::HashMap::new(), dedup: Deduplicator::new(), + // RotorHazard is the full-signal case, so trace capture is on by default. + signal_capture: true, + node_data_period_micros: DEFAULT_NODE_DATA_PERIOD_MICROS, + race_active: false, + sample_index: std::collections::HashMap::new(), + last_thresholds: std::collections::HashMap::new(), + pending_marshal_request: false, + live_signal_active: false, + dense_accum: std::collections::HashMap::new(), + pending_pilotrace_requests: Vec::new(), + pilotrace_start_time: std::collections::HashMap::new(), + pending_heat_ids: Vec::new(), + pending_heat_slots: std::collections::HashMap::new(), + pending_pilot_ids: Vec::new(), } } + /// Take (and clear) the configured heat ids learned from the most recent `heat_data`. + /// + /// The transport calls this after feeding a `heat_data` payload: it selects one of the returned + /// ids as the current (savable) heat (`set_current_heat`) so RotorHazard persists the run's dense + /// history. Empty when no `heat_data` has been folded since the last drain. + pub fn take_heat_ids(&mut self) -> Vec { + std::mem::take(&mut self.pending_heat_ids) + } + + /// Take (and clear) the per-node slot ids of each configured heat learned from the most recent + /// `heat_data`. The transport calls this when **seating** a heat's bound pilots: it picks the + /// freshest heat (the one it just `add_heat`ed) and reads each node's slot id to assign a pilot + /// (`alter_heat { heat, slot_id, pilot }`). Empty when no `heat_data` has been folded. + pub fn take_heat_slots( + &mut self, + ) -> std::collections::HashMap> { + std::mem::take(&mut self.pending_heat_slots) + } + + /// Take (and clear) the configured pilot ids learned from the most recent `pilot_data`. The + /// transport calls this after creating a pilot (`add_pilot`) when seating a heat's bound pilots: + /// the **highest** id is the pilot just added, to be assigned onto a heat seat. Empty when no + /// `pilot_data` has been folded. + pub fn take_pilot_ids(&mut self) -> Vec { + std::mem::take(&mut self.pending_pilot_ids) + } + + /// Take (and clear) the per-pilotrace marshal pulls discovered from the most recent `race_list`. + /// + /// The transport calls this after feeding a `race_list` payload: each returned + /// [`PilotRaceRequest`] should be issued as a `get_pilotrace` so its `race_details` response folds + /// into dense history. Draining clears the queue so the same `race_list` is not pulled twice. + pub fn take_pilotrace_requests(&mut self) -> Vec { + std::mem::take(&mut self.pending_pilotrace_requests) + } + + /// Take (and clear) the "request the dense marshal data" flag, set when a race reached `DONE`. + /// + /// The transport calls this after feeding a `race_status` payload through the adapter: a `true` + /// return means a heat just ended on a signal-capable adapter, so the transport should emit + /// RotorHazard's `current_race_marshal` request and let the `current_marshal_data` response feed + /// back through [`translate`](Adapter::translate) as [`Event::SignalHistory`]. It is one-shot per + /// heat end — draining clears it so a re-sent `DONE` (a reconnect replay) does not re-request. + pub fn take_marshal_request(&mut self) -> bool { + std::mem::take(&mut self.pending_marshal_request) + } + /// The capability profile for RotorHazard — the full-signal case (see module docs). pub fn capabilities_for_source() -> Capabilities { Capabilities::none() @@ -315,6 +795,11 @@ impl RotorHazardAdapter { let competitor = seat_ref(node_index); for lap in node.laps { + // RH 4.3+/4.4 may carry deleted laps inline (older RH pre-filtered them); + // never mint a pass for one. + if lap.deleted == Some(true) { + continue; + } let signal = self .pass_peak_rssi .get(&node_index) @@ -332,6 +817,8 @@ impl RotorHazardAdapter { // RotorHazard reports the lap gate only (single start/finish gate). gate: GateIndex::LAP, signal, + // The adapter doesn't know the heat; the bridge sink stamps it at append. + heat: None, }; // A re-sent snapshot replays every lap; only accept genuinely new ones. @@ -368,26 +855,406 @@ impl RotorHazardAdapter { } match status.race_status { - race_status::RACING => out.push(Event::SessionStarted { - adapter: self.id.clone(), - session: Self::session_id(status.race_heat_id), - }), - race_status::DONE => out.push(Event::SessionEnded { - adapter: self.id.clone(), - session: Self::session_id(status.race_heat_id), - }), + race_status::RACING => { + // A genuinely new race starts here (this arm only runs on a real + // transition *into* RACING — `previous != Some(RACING)`). RotorHazard + // resets each node's `lap_number` to 0 at the start of every race, and + // the per-lap dedup is keyed on that `(competitor, sequence=lap_number)`. + // Without a reset, heat 2's lap 0–N collide with heat 1's already-seen + // lap 0–N over a persistent connection and every lap past the first heat + // is suppressed (#105 cross-heat bug). Reset the per-race dedup + seat + // state on the RACING edge so each heat starts fresh. + // + // This is safe for the reconnect-dedup guarantee: a re-sent + // `current_laps` snapshot carries *no* status transition, so it never + // reaches this arm; and a *mid-race* reconnect keeps + // `last_race_status == RACING` (so this arm does NOT fire — no reset), + // leaving the replayed snapshot suppressed. The persistent driver + // **reuses one `RotorHazardAdapter` across reconnects** (#105 fix: + // `connect` takes it, `disconnect` returns it), so `last_race_status` + // and the dedup are continuous — a mid-race reconnect does not reset + // and its replayed laps stay deduped. This reset therefore fires only + // on a true new-race edge within that adapter's (now connection- + // spanning) lifetime. + self.dedup = Deduplicator::new(); + self.seen_seats.clear(); + // Marshaling Slice 1: a fresh race resets the trace's time base so each heat's + // captured chunks start at source-time 0 — deterministic and heat-local. + self.race_active = true; + self.sample_index.clear(); + self.last_thresholds.clear(); + // A fresh race invalidates any stale marshal-pull state from the previous heat. + self.pending_marshal_request = false; + // The dense trace is per-heat: drop the previous heat's accumulator so the new heat + // builds a fresh trace. (`live_signal_active` persists — once the plugin is + // streaming, it streams every heat.) + self.dense_accum.clear(); + self.pending_pilotrace_requests.clear(); + self.pilotrace_start_time.clear(); + out.push(Event::SessionStarted { + adapter: self.id.clone(), + session: Self::session_id(status.race_heat_id), + }); + } + race_status::DONE => { + // Stop capturing trace samples once the race closes (idle `node_data` is not + // heat evidence). + self.race_active = false; + // The heat just ended: a signal-capable adapter should now pull RotorHazard's dense + // `current_marshal_data` history (the full-fidelity trace its marshal page reviews). + // Record the intent for the transport to act on — the pure translator can't emit the + // socket request itself. Only on a genuine RACING/STAGING -> DONE edge (this arm runs + // once per transition), so a re-sent DONE on a reconnect does not re-request. + // ...unless the GridFPV plugin is already pushing the dense trace live (Slice 2): + // then the full-fidelity history has arrived in-band and the pull is redundant. + if self.signal_capture && !self.live_signal_active { + self.pending_marshal_request = true; + } + out.push(Event::SessionEnded { + adapter: self.id.clone(), + session: Self::session_id(status.race_heat_id), + }) + } // READY (reset) and STAGING (pre-roll) carry no canonical lifecycle edge. race_status::READY | race_status::STAGING => {} _ => {} } } - /// Update the per-node RSSI cache from a `node_data` message. Emits no events; the - /// cache annotates subsequent passes' [`SignalContext`]. - fn update_node_data(&mut self, data: RawNodeData) { + /// Update the per-node RSSI cache from a `node_data` message, and — for a signal-capable + /// adapter while a race is active — emit one [`Event::SignalChunk`] trace sample per node. + /// + /// The cache (`pass_peak_rssi`) annotates subsequent passes' [`SignalContext`] (unchanged). + /// The trace samples `node_peak_rssi` (the live signal level), falling back to + /// `pass_peak_rssi` on an older payload that omits it. Each sample is placed on the source + /// clock at `sample_index[node] * node_data_period_micros`, so concatenating a node's chunks + /// reconstructs a contiguous, deterministic trace (`gridfpv_projection::signal_trace`). RSSI + /// is clamped into `u16` ADC counts (RH's stock range is ~0–255; negatives clamp to 0). + fn update_node_data(&mut self, data: RawNodeData, out: &mut Vec) { for (node_index, &rssi) in data.pass_peak_rssi.iter().enumerate() { self.pass_peak_rssi.insert(node_index, rssi); } + + // Trace capture is gated on the signal capability and only runs while a race is active. + if !self.signal_capture || !self.race_active { + return; + } + // Sample the live node peak where present; older payloads without it fall back to the + // per-pass peak so a trace is still captured at the same cadence. + let samples = if data.node_peak_rssi.is_empty() { + &data.pass_peak_rssi + } else { + &data.node_peak_rssi + }; + for (node_index, &rssi) in samples.iter().enumerate() { + let sample = rssi.round().clamp(0.0, u16::MAX as f32) as u16; + let index = self.sample_index.entry(node_index).or_insert(0); + let from = SourceTime::from_micros(*index as i64 * self.node_data_period_micros as i64); + *index += 1; + out.push(Event::SignalChunk(SignalChunk { + adapter: self.id.clone(), + competitor: seat_ref(node_index), + from, + period_micros: self.node_data_period_micros, + rssi: vec![sample], + })); + } + } + + /// Translate a `current_marshal_data` response (newer RotorHazard) into one + /// [`Event::SignalHistory`] per seat that carries a dense trace — the **full-fidelity** per-tick + /// RSSI history RotorHazard records, which supersedes the coarse streamed [`SignalChunk`] samples + /// in the `signal_trace` projection. Each seat's `start_time` is the race origin from the payload + /// header. Gated on the signal capability. + fn translate_marshal_data(&mut self, data: RawMarshalData, out: &mut Vec) { + if !self.signal_capture { + return; + } + let start_time = data.race.as_ref().map(|r| r.start_time).unwrap_or(0.0); + for (key, seat) in data.seats { + // Seats are keyed by stringified node index; a non-numeric key is not a node seat. + let Ok(node_index) = key.parse::() else { + continue; + }; + self.emit_dense_history( + node_index, + start_time, + &seat.history_times, + &seat.history_values, + out, + ); + } + } + + /// Translate a `race_list` (saved-race tree) into the per-pilotrace pulls the transport issues. + /// + /// Walks every heat/round, recording each `(pilotrace_id)` to pull and the round `start_time` per + /// `node_index` (so the later `race_details` history can be made race-relative). The pulls queue + /// in [`pending_pilotrace_requests`](Self::pending_pilotrace_requests) for the transport to drain; + /// this emits no canonical events. Idempotent within a heat: a re-sent `race_list` rebuilds the + /// same queue (the transport drains it once per send). + fn translate_race_list(&mut self, list: RawRaceList) { + if !self.signal_capture { + return; + } + let mut requests = Vec::new(); + for heat in list.heats.into_values() { + for round in heat.rounds.into_values() { + for pr in round.pilotraces { + if let Some(node_index) = pr.node_index { + self.pilotrace_start_time + .insert(node_index, round.start_time); + } + requests.push(PilotRaceRequest { + pilotrace_id: pr.pilotrace_id, + }); + } + } + } + self.pending_pilotrace_requests = requests; + } + + /// Translate a `race_details` (one saved pilotrace) into that seat's dense [`Event::SignalHistory`] + /// — the per-pilotrace marshal path on the RotorHazard build with no aggregate + /// `current_marshal_data`. Refreshes the seat's [`SignalThresholds`] from `enter_at`/`exit_at` + /// when present. Uses the round `start_time` learned from the `race_list` as the race origin (or + /// the first sample when none is known). Gated on the signal capability. + fn translate_race_details(&mut self, details: RawRaceDetails, out: &mut Vec) { + if !self.signal_capture { + return; + } + let Some(node_index) = details.node_index else { + return; + }; + // Prefer the race start learned from the race_list; else anchor on the first sample so the + // trace is trace-relative (starts at 0) — matching the coarse trace's per-heat origin. + let start_time = self + .pilotrace_start_time + .get(&node_index) + .copied() + .or_else(|| details.history_times.first().copied()) + .unwrap_or(0.0); + self.emit_dense_history( + node_index, + start_time, + &details.history_times, + &details.history_values, + out, + ); + // Refresh thresholds the call was made against, if reported (last-writer-wins downstream). + if let (Some(enter), Some(exit)) = (details.enter_at, details.exit_at) { + let enter = enter.round().clamp(0.0, u16::MAX as f32 as f64) as u16; + let exit = exit.round().clamp(0.0, u16::MAX as f32 as f64) as u16; + out.push(Event::SignalThresholds(SignalThresholds { + adapter: self.id.clone(), + competitor: seat_ref(node_index), + enter, + exit, + })); + } + } + + /// Emit one dense [`Event::SignalHistory`] for a seat from its parallel `times`/`values` arrays. + /// + /// `start_time` is the race origin in **seconds**; each `times[i]` (also seconds) is made + /// race-relative by subtracting it, then converted to integer microseconds (the [`SourceTime`] + /// unit). RSSI clamps into `u16` ADC counts, native units, **no resampling** (the Slice 1 + /// fidelity caution). Mismatched-length arrays use the common prefix; an empty history emits + /// nothing. Shared by the `current_marshal_data` and `race_details` paths so the two RH builds + /// fold identically. + fn emit_dense_history( + &self, + node_index: usize, + start_time: f64, + history_times: &[f64], + history_values: &[f64], + out: &mut Vec, + ) { + let n = history_values.len().min(history_times.len()); + if n == 0 { + return; + } + let mut times = Vec::with_capacity(n); + let mut rssi = Vec::with_capacity(n); + for i in 0..n { + // Race-relative seconds -> integer microseconds (round half away from zero); clamp to + // non-negative so a sample fractionally before the recorded start can't go negative. + let rel_secs = history_times[i] - start_time; + let micros = (rel_secs * 1_000_000.0).round().max(0.0) as i64; + times.push(micros); + rssi.push(history_values[i].round().clamp(0.0, u16::MAX as f32 as f64) as u16); + } + out.push(Event::SignalHistory(SignalHistory { + adapter: self.id.clone(), + competitor: seat_ref(node_index), + times, + rssi, + })); + } + + /// Record the configured heat ids from a `heat_data` response for the transport to drain. + /// + /// Emits no canonical events — `heat_data` is a transport routing payload. The ids queue in + /// [`pending_heat_ids`](Self::pending_heat_ids); the transport picks one to make current so the + /// run is savable (the dense-history precondition). A re-sent `heat_data` rebuilds the list. + fn translate_heat_data(&mut self, data: RawHeatData) { + self.pending_heat_ids = data.heats.iter().map(|h| h.id).collect(); + self.pending_heat_slots = data + .heats + .into_iter() + .map(|h| { + let node_to_slot = h + .slots + .into_iter() + .filter_map(|s| s.node_index.map(|n| (n, s.id))) + .collect(); + (h.id, node_to_slot) + }) + .collect(); + } + + /// Record the configured pilot ids from a `pilot_data` response for the transport to drain. + /// + /// Emits no canonical events — `pilot_data` is a transport routing payload. The ids queue in + /// [`pending_pilot_ids`](Self::pending_pilot_ids); the transport reads the highest (the pilot it + /// just `add_pilot`ed) to assign onto a heat seat when seating. A re-sent `pilot_data` rebuilds + /// the list. + fn translate_pilot_data(&mut self, data: RawPilotData) { + self.pending_pilot_ids = data.pilots.into_iter().map(|p| p.pilot_id).collect(); + } + + /// Emit per-node [`Event::SignalThresholds`] from an `enter_and_exit_at_levels` message — + /// for a signal-capable adapter only. A node is emitted once and then only when its + /// `(enter, exit)` pair changes, so a steady re-send does not spam the log. + fn update_thresholds(&mut self, levels: RawEnterExitLevels, out: &mut Vec) { + if !self.signal_capture { + return; + } + let n = levels + .enter_at_levels + .len() + .min(levels.exit_at_levels.len()); + for node_index in 0..n { + self.emit_threshold( + node_index, + levels.enter_at_levels[node_index] as f64, + levels.exit_at_levels[node_index] as f64, + out, + ); + } + } + + /// Emit a [`SignalThresholds`] for one seat, deduped against the last `(enter, exit)` seen so a + /// re-sent (unchanged) level does not re-emit. Shared by the `enter_and_exit_at_levels` path and + /// the plugin's `gridfpv_signal` path. + fn emit_threshold(&mut self, node_index: usize, enter: f64, exit: f64, out: &mut Vec) { + let enter = enter.round().clamp(0.0, u16::MAX as f64) as u16; + let exit = exit.round().clamp(0.0, u16::MAX as f64) as u16; + if self.last_thresholds.get(&node_index) == Some(&(enter, exit)) { + return; + } + self.last_thresholds.insert(node_index, (enter, exit)); + out.push(Event::SignalThresholds(SignalThresholds { + adapter: self.id.clone(), + competitor: seat_ref(node_index), + enter, + exit, + })); + } + + /// Fold a `gridfpv_signal` broadcast from the GridFPV RH plugin (D16, Slice 2) into canonical + /// signal facts — the **live** equivalent of the post-race save-then-pull. Per node it refreshes + /// the detection [`SignalThresholds`] and, when the dense history has **grown**, emits an updated + /// dense [`SignalHistory`] (the same full-fidelity trace the marshal-data path produces, made + /// race-relative via `race_start`). Seeing any broadcast marks [`live_signal_active`], which + /// suppresses the redundant post-race pull on the DONE edge. + /// + /// The plugin re-sends the full window each tick; the per-node grown-length check + /// ([`last_history_len`](Self::last_history_len)) keeps the log to ~one dense fact per crossing + /// rather than one per broadcast. The live coarse trace still comes from `node_data` until the + /// first dense history supersedes it in the projection. + fn translate_grid_signal(&mut self, sig: RawGridSignal, out: &mut Vec) { + if !self.signal_capture { + return; + } + self.live_signal_active = true; + for node in sig.nodes { + let node_index = node.index; + if let (Some(enter), Some(exit)) = (node.enter_at, node.exit_at) { + self.emit_threshold(node_index, enter, exit, out); + } + // Dense history (S2.1, incremental): convert this slice to race-relative µs and apply it + // to the per-node accumulator — REPLACE on a full snapshot (`base == 0`), APPEND when it + // continues the accumulator (`base == len`), else skip an out-of-sync slice. Emit the + // accumulated trace only when it actually changed, so a redundant final flush is a no-op. + let Some(start) = sig.race_start else { + continue; + }; + let n = node.history_values.len().min(node.history_times.len()); + let mut times = Vec::with_capacity(n); + let mut rssi = Vec::with_capacity(n); + for i in 0..n { + let rel_secs = node.history_times[i] - start; + times.push((rel_secs * 1_000_000.0).round().max(0.0) as i64); + rssi.push(node.history_values[i].round().clamp(0.0, u16::MAX as f64) as u16); + } + let acc = self.dense_accum.entry(node_index).or_default(); + let changed = if node.base == 0 { + if acc.0 != times || acc.1 != rssi { + *acc = (times, rssi); + !acc.0.is_empty() + } else { + false + } + } else if node.base == acc.0.len() && n > 0 { + acc.0.extend(times); + acc.1.extend(rssi); + true + } else { + false // out-of-sync slice; wait for the next full snapshot to resync + }; + if changed { + out.push(Event::SignalHistory(SignalHistory { + adapter: self.id.clone(), + competitor: seat_ref(node_index), + times: acc.0.clone(), + rssi: acc.1.clone(), + })); + } + } + } + + /// Fold a `gridfpv_pass` broadcast (D16, Slice 3) into a canonical [`Pass`], attributed by node + /// seat. Mirrors [`translate_current_laps`](Self::translate_current_laps): same + /// `(competitor, sequence=lap_number)` dedup (so the plugin's native pass and the `current_laps` + /// re-pass never double-count — whichever arrives first wins), and a seat's first surfaced pass + /// announces it as [`Event::CompetitorSeen`]. + fn translate_grid_pass(&mut self, p: RawGridPass, out: &mut Vec) { + let node_index = p.node_index; + let competitor = seat_ref(node_index); + let signal = p.peak_rssi.map(|rssi| SignalContext { + rssi_peak: Some(rssi as f32), + }); + let pass = Pass { + adapter: self.id.clone(), + competitor: competitor.clone(), + at: Self::lap_stamp_to_source_time(p.lap_time_stamp), + sequence: Some(p.lap_number), + gate: GateIndex::LAP, + signal, + // The adapter doesn't know the heat; the bridge sink stamps it at append. + heat: None, + }; + if !self.dedup.observe(&pass) { + return; + } + if self.seen_seats.insert(node_index) { + out.push(Event::CompetitorSeen { + adapter: self.id.clone(), + competitor: competitor.clone(), + }); + } + out.push(Event::Pass(pass)); } } @@ -415,7 +1282,15 @@ impl Adapter for RotorHazardAdapter { Raw::CurrentLaps(snapshot) => self.translate_current_laps(snapshot, &mut out), // pass_record is an advisory cross-check; it mints no canonical events. Raw::PassRecord(_) => {} - Raw::NodeData(data) => self.update_node_data(data), + Raw::NodeData(data) => self.update_node_data(data, &mut out), + Raw::EnterExitLevels(levels) => self.update_thresholds(levels, &mut out), + Raw::MarshalData(data) => self.translate_marshal_data(data, &mut out), + Raw::RaceList(list) => self.translate_race_list(list), + Raw::RaceDetails(details) => self.translate_race_details(details, &mut out), + Raw::HeatData(data) => self.translate_heat_data(data), + Raw::PilotData(data) => self.translate_pilot_data(data), + Raw::GridSignal(sig) => self.translate_grid_signal(sig, &mut out), + Raw::GridPass(pass) => self.translate_grid_pass(pass, &mut out), } out } @@ -464,6 +1339,7 @@ mod tests { lap_time: None, lap_time_stamp, late_lap: false, + deleted: None, } } @@ -485,7 +1361,15 @@ mod tests { #[test] fn recorded_session_translates_to_canonical_events() { let mut adapter = RotorHazardAdapter::with_id(AdapterId("rh".into())); - let events = run(&mut adapter, parse(SESSION_FIXTURE)); + let all = run(&mut adapter, parse(SESSION_FIXTURE)); + // The fixture's `node_data` ticks now also produce SignalChunk trace samples (Slice 1); + // this test asserts the lap/lifecycle backbone, so filter the trace out and assert it in + // `recorded_session_captures_signal_trace` separately. + let events: Vec = all + .iter() + .filter(|e| !matches!(e, Event::SignalChunk(_) | Event::SignalThresholds(_))) + .cloned() + .collect(); let rh = AdapterId("rh".into()); let heat = SessionId("heat-0".into()); @@ -509,6 +1393,7 @@ mod tests { signal: Some(SignalContext { rssi_peak: Some(0.0), }), + heat: None, }), // node-1 holeshot (lap 0): ts 5416.201... ms -> 5_416_202 µs. Event::CompetitorSeen { @@ -524,6 +1409,7 @@ mod tests { signal: Some(SignalContext { rssi_peak: Some(0.0), }), + heat: None, }), // node-0 lap 1: ts 7416.519... ms -> 7_416_519 µs (re-sent snapshot adds it). Event::Pass(Pass { @@ -535,6 +1421,7 @@ mod tests { signal: Some(SignalContext { rssi_peak: Some(0.0), }), + heat: None, }), // node-0 lap 2: ts 10017.685... ms -> 10_017_685 µs. Event::Pass(Pass { @@ -546,6 +1433,7 @@ mod tests { signal: Some(SignalContext { rssi_peak: Some(0.0), }), + heat: None, }), // DONE (2) ends the session. Event::SessionEnded { @@ -583,6 +1471,7 @@ mod tests { // node_data first, then a lap on node 0 picks up its cached RSSI. adapter.translate(Raw::NodeData(RawNodeData { pass_peak_rssi: vec![201.5, 0.0], + node_peak_rssi: vec![201.5, 0.0], })); let events = adapter.translate(snapshot(0, 0, vec![lap(0, 1_000.0)])); let pass = events @@ -600,6 +1489,180 @@ mod tests { ); } + /// Helper: collect the `SignalChunk`s in an event slice. + fn chunks(events: &[Event]) -> Vec<&SignalChunk> { + events + .iter() + .filter_map(|e| match e { + Event::SignalChunk(c) => Some(c), + _ => None, + }) + .collect() + } + + #[test] + fn node_data_captures_a_trace_only_while_racing() { + let mut adapter = RotorHazardAdapter::new(); + // Idle `node_data` before RACING is monitoring churn — no trace captured. + let idle = adapter.translate(Raw::NodeData(RawNodeData { + pass_peak_rssi: vec![70.0, 60.0], + node_peak_rssi: vec![70.0, 60.0], + })); + assert!(chunks(&idle).is_empty(), "no trace before the race starts"); + + // RACING opens capture. + adapter.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::RACING, + race_heat_id: Some(0), + })); + let t0 = adapter.translate(Raw::NodeData(RawNodeData { + pass_peak_rssi: vec![150.0, 120.0], + node_peak_rssi: vec![150.0, 120.0], + })); + let t1 = adapter.translate(Raw::NodeData(RawNodeData { + pass_peak_rssi: vec![151.0, 121.0], + node_peak_rssi: vec![151.0, 121.0], + })); + + // One chunk per node per tick, sampling node_peak_rssi, anchored on the per-node index. + let c0 = chunks(&t0); + assert_eq!(c0.len(), 2); + assert_eq!(c0[0].competitor, CompetitorRef("node-0".into())); + assert_eq!(c0[0].rssi, vec![150]); + assert_eq!(c0[0].from, SourceTime::from_micros(0)); + assert_eq!(c0[0].period_micros, DEFAULT_NODE_DATA_PERIOD_MICROS); + assert_eq!(c0[1].competitor, CompetitorRef("node-1".into())); + assert_eq!(c0[1].rssi, vec![120]); + + // The second tick advances each node's sample index by one period. + let c1 = chunks(&t1); + assert_eq!(c1[0].from, SourceTime::from_micros(100_000)); + assert_eq!(c1[0].rssi, vec![151]); + + // DONE stops capture. + let done = adapter.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::DONE, + race_heat_id: Some(0), + })); + assert!(chunks(&done).is_empty()); + let after = adapter.translate(Raw::NodeData(RawNodeData { + pass_peak_rssi: vec![70.0, 60.0], + node_peak_rssi: vec![70.0, 60.0], + })); + assert!(chunks(&after).is_empty(), "no trace after the race ends"); + } + + #[test] + fn node_data_trace_falls_back_to_pass_peak_when_node_peak_absent() { + let mut adapter = RotorHazardAdapter::new(); + adapter.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::RACING, + race_heat_id: Some(0), + })); + // An older payload with no node_peak_rssi: the trace samples pass_peak_rssi instead. + let t = adapter.translate(Raw::NodeData(RawNodeData { + pass_peak_rssi: vec![88.0], + node_peak_rssi: vec![], + })); + let c = chunks(&t); + assert_eq!(c.len(), 1); + assert_eq!(c[0].rssi, vec![88]); + } + + #[test] + fn race_restart_resets_the_trace_time_base() { + let mut adapter = RotorHazardAdapter::new(); + adapter.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::RACING, + race_heat_id: Some(1), + })); + adapter.translate(Raw::NodeData(RawNodeData { + pass_peak_rssi: vec![100.0], + node_peak_rssi: vec![100.0], + })); + adapter.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::DONE, + race_heat_id: Some(1), + })); + // A new heat resets the per-node sample index back to 0. + adapter.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::RACING, + race_heat_id: Some(2), + })); + let t = adapter.translate(Raw::NodeData(RawNodeData { + pass_peak_rssi: vec![100.0], + node_peak_rssi: vec![100.0], + })); + assert_eq!(chunks(&t)[0].from, SourceTime::from_micros(0)); + } + + #[test] + fn enter_exit_levels_emit_thresholds_once_until_changed() { + let mut adapter = RotorHazardAdapter::new(); + let first = adapter.translate(Raw::EnterExitLevels(RawEnterExitLevels { + enter_at_levels: vec![90.0, 92.0], + exit_at_levels: vec![80.0, 82.0], + })); + let thresholds: Vec<&SignalThresholds> = first + .iter() + .filter_map(|e| match e { + Event::SignalThresholds(t) => Some(t), + _ => None, + }) + .collect(); + assert_eq!(thresholds.len(), 2); + assert_eq!((thresholds[0].enter, thresholds[0].exit), (90, 80)); + + // An unchanged re-send emits nothing. + let resent = adapter.translate(Raw::EnterExitLevels(RawEnterExitLevels { + enter_at_levels: vec![90.0, 92.0], + exit_at_levels: vec![80.0, 82.0], + })); + assert!( + !resent + .iter() + .any(|e| matches!(e, Event::SignalThresholds(_))), + "steady thresholds are not re-emitted" + ); + + // A changed value re-emits for that node only. + let changed = adapter.translate(Raw::EnterExitLevels(RawEnterExitLevels { + enter_at_levels: vec![90.0, 95.0], + exit_at_levels: vec![80.0, 82.0], + })); + let changed_t: Vec<&SignalThresholds> = changed + .iter() + .filter_map(|e| match e { + Event::SignalThresholds(t) => Some(t), + _ => None, + }) + .collect(); + assert_eq!(changed_t.len(), 1); + assert_eq!(changed_t[0].competitor, CompetitorRef("node-1".into())); + assert_eq!((changed_t[0].enter, changed_t[0].exit), (95, 82)); + } + + #[test] + fn recorded_session_captures_signal_trace() { + // The recorded fixture drives RACING then several all-zero `node_data` ticks: the trace + // captures one zero sample per node per tick while racing, anchored at source-time 0. + let mut adapter = RotorHazardAdapter::with_id(AdapterId("rh".into())); + let events = run(&mut adapter, parse(SESSION_FIXTURE)); + let view = gridfpv_projection::signal_trace(&events); + // Three nodes appear in node_data; each gets a trace. + assert_eq!(view.competitors.len(), 3); + let node0 = view + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "node-0") + .expect("node-0 trace"); + // The fixture sends 4 node_data ticks while racing -> 4 samples, all zero (mock nodes). + assert_eq!(node0.samples.len(), 4); + assert!(node0.samples.iter().all(|&s| s == 0)); + assert_eq!(node0.from, Some(SourceTime::from_micros(0))); + assert_eq!(node0.period_micros, DEFAULT_NODE_DATA_PERIOD_MICROS); + } + #[test] fn resent_snapshot_does_not_double_emit() { let mut adapter = RotorHazardAdapter::new(); @@ -668,6 +1731,96 @@ mod tests { assert_eq!(pass.competitor, CompetitorRef("node-0".into())); } + #[test] + fn heat_data_records_ids_for_the_transport_and_emits_no_events() { + // `heat_data` is a transport routing payload: it mints no canonical events but records the + // configured heat ids so the transport can select a savable current heat before staging + // (the marshaling path-2 precondition). + let mut adapter = RotorHazardAdapter::new(); + let events = adapter.translate(Raw::HeatData(RawHeatData { + heats: vec![ + RawHeat { + id: 1, + slots: vec![], + }, + RawHeat { + id: 4, + slots: vec![], + }, + RawHeat { + id: 2, + slots: vec![], + }, + ], + })); + assert!(events.is_empty(), "heat_data emits no canonical events"); + // The ids are exposed for the transport to drain, then cleared (one-shot per send). + assert_eq!(adapter.take_heat_ids(), vec![1, 4, 2]); + assert!( + adapter.take_heat_ids().is_empty(), + "draining clears the heat ids" + ); + } + + #[test] + fn heat_data_records_per_node_slot_ids_for_seating() { + // Seating a heat's bound pilots needs each node's slot id (the `HeatNode` PK `alter_heat` + // targets). The adapter records heat id → (node_index → slot_id) from `heat_data` for the + // transport to drain when it seats. + let mut adapter = RotorHazardAdapter::new(); + adapter.translate(Raw::HeatData(RawHeatData { + heats: vec![RawHeat { + id: 7, + slots: vec![ + RawHeatSlot { + id: 21, + node_index: Some(0), + }, + RawHeatSlot { + id: 22, + node_index: Some(1), + }, + // An unprogrammed slot (no node) is ignored. + RawHeatSlot { + id: 23, + node_index: None, + }, + ], + }], + })); + let slots = adapter.take_heat_slots(); + let heat = slots.get(&7).expect("heat 7 slots recorded"); + assert_eq!(heat.get(&0), Some(&21), "node-0 maps to slot 21"); + assert_eq!(heat.get(&1), Some(&22), "node-1 maps to slot 22"); + assert_eq!(heat.len(), 2, "the unprogrammed (no-node) slot is dropped"); + assert!( + adapter.take_heat_slots().is_empty(), + "draining clears the heat slots" + ); + } + + #[test] + fn pilot_data_records_ids_for_seating_and_emits_no_events() { + // `pilot_data` is a transport routing payload: it mints no canonical events but records the + // configured pilot ids so the transport can learn the id of a pilot it just `add_pilot`ed + // (the highest) to assign onto a heat seat when seating. + let mut adapter = RotorHazardAdapter::new(); + let events = adapter.translate(Raw::PilotData(RawPilotData { + pilots: vec![ + RawPilotEntry { pilot_id: 1 }, + RawPilotEntry { pilot_id: 5 }, + RawPilotEntry { pilot_id: 3 }, + ], + })); + assert!(events.is_empty(), "pilot_data emits no canonical events"); + // The transport reads the highest (the just-added pilot). + assert_eq!(adapter.take_pilot_ids().into_iter().max(), Some(5)); + assert!( + adapter.take_pilot_ids().is_empty(), + "draining clears the pilot ids" + ); + } + #[test] fn pass_record_emits_nothing() { let mut adapter = RotorHazardAdapter::new(); @@ -714,6 +1867,550 @@ mod tests { ); } + /// Count the `Pass`es in a slice of events. + fn pass_count(events: &[Event]) -> usize { + events + .iter() + .filter(|e| matches!(e, Event::Pass(_))) + .count() + } + + /// The cross-heat regression (#105): over one persistent connection RotorHazard + /// resets each node's `lap_number` to 0 at the start of every race, so heat 2's + /// laps reuse heat 1's sequences. Resetting dedup on the RACING transition makes + /// heat 2 ingest its laps; pre-fix all four were suppressed (0 passes). + #[test] + fn cross_heat_laps_are_not_deduped_against_the_previous_heat() { + let mut adapter = RotorHazardAdapter::new(); + + // Heat 1: RACING, then node-0 laps 0..=3. + adapter.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::RACING, + race_heat_id: Some(1), + })); + let heat1 = adapter.translate(snapshot( + 0, + 0, + vec![ + lap(0, 1_000.0), + lap(1, 2_000.0), + lap(2, 3_000.0), + lap(3, 4_000.0), + ], + )); + assert_eq!(pass_count(&heat1), 4, "heat 1 emits all four laps"); + + // Heat 1 finishes. + adapter.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::DONE, + race_heat_id: Some(1), + })); + + // Heat 2: a fresh RACING transition — RH restarts lap_number at 0. + adapter.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::RACING, + race_heat_id: Some(2), + })); + let heat2 = adapter.translate(snapshot( + 0, + 0, + vec![ + lap(0, 1_000.0), + lap(1, 2_000.0), + lap(2, 3_000.0), + lap(3, 4_000.0), + ], + )); + assert_eq!( + pass_count(&heat2), + 4, + "heat 2 must emit four fresh passes (pre-fix: 0 — all deduped against heat 1)" + ); + } + + /// The #105 reconnect invariant still holds within a race: a re-sent `current_laps` + /// snapshot with **no** status transition must remain deduped (the RACING reset only + /// fires on a genuine new-race edge, not on a snapshot replay). + #[test] + fn resent_snapshot_within_a_race_is_still_deduped() { + let mut adapter = RotorHazardAdapter::new(); + + adapter.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::RACING, + race_heat_id: Some(1), + })); + let laps = vec![ + lap(0, 1_000.0), + lap(1, 2_000.0), + lap(2, 3_000.0), + lap(3, 4_000.0), + ]; + let first = adapter.translate(snapshot(0, 0, laps.clone())); + assert_eq!(pass_count(&first), 4, "first snapshot emits all four laps"); + + // The same snapshot re-sent (e.g. a reconnect replays it) — no status edge. + let resent = adapter.translate(snapshot(0, 0, laps)); + assert_eq!( + pass_count(&resent), + 0, + "a re-sent snapshot within the same race emits no new passes" + ); + } + + /// The mid-race reconnect regression (#105): when the persistent driver's RH link drops and + /// reconnects *during* a running heat, RotorHazard re-sends the full in-progress `current_laps` + /// snapshot on the new socket — with `last_race_status` still `RACING`, so there is **no** status + /// transition (no #156 reset). The fix persists the SAME adapter across the reconnect, so its + /// dedup already holds those laps and the replay is suppressed (0 new passes). The old behavior — + /// building a *fresh* adapter on every reconnection — is what this test encodes as the bug: a + /// fresh adapter has an empty dedup and re-emits every in-progress lap, which the lap projection + /// (no sequence dedup) turns into duplicate laps. We assert both: the persisted adapter dedups, + /// the fresh adapter double-emits. + #[test] + fn mid_race_reconnect_with_persisted_adapter_does_not_double_count() { + // A heat is racing; node-0 has run four in-progress laps. + let mut adapter = RotorHazardAdapter::new(); + adapter.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::RACING, + race_heat_id: Some(7), + })); + let in_progress = vec![ + lap(0, 1_000.0), + lap(1, 2_000.0), + lap(2, 3_000.0), + lap(3, 4_000.0), + ]; + let before = adapter.translate(snapshot(0, 0, in_progress.clone())); + assert_eq!( + pass_count(&before), + 4, + "the four in-progress laps are ingested" + ); + + // The link drops and reconnects mid-race. The driver REUSES this adapter (the #105 fix: + // `connect` takes it, `disconnect` returns it), so `last_race_status` is still RACING. On a + // reconnect RH replays the full in-progress state: first the same `race_status=RACING` (NOT + // a transition — `previous == Some(RACING)` — so no SessionStarted, no #156 dedup reset)... + let replayed_status = adapter.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::RACING, + race_heat_id: Some(7), + })); + assert!( + replayed_status.is_empty(), + "re-sent RACING after a mid-race reconnect is not a transition (no reset)" + ); + // ...then the re-sent `current_laps` snapshot of the very same laps. + let after = adapter.translate(snapshot(0, 0, in_progress.clone())); + assert_eq!( + pass_count(&after), + 0, + "a mid-race reconnect's replayed snapshot must emit no new passes (no double-count)" + ); + + // Contrast — the OLD behavior the fix removes: a FRESH adapter (empty dedup) re-emits every + // replayed lap as a duplicate. This is exactly the double-count the persisted adapter avoids. + let mut fresh = RotorHazardAdapter::new(); + fresh.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::RACING, + race_heat_id: Some(7), + })); + let fresh_after = fresh.translate(snapshot(0, 0, in_progress)); + assert_eq!( + pass_count(&fresh_after), + 4, + "a fresh adapter (old reconnect behavior) double-emits the in-progress laps" + ); + } + + /// Collect the `SignalHistory` events in a slice. + fn histories(events: &[Event]) -> Vec<&SignalHistory> { + events + .iter() + .filter_map(|e| match e { + Event::SignalHistory(h) => Some(h), + _ => None, + }) + .collect() + } + + /// Build a `current_marshal_data` payload with a race `start_time` and one seat. + fn marshal_data(start_time: f64, seat: usize, times: &[f64], values: &[f64]) -> Raw { + let mut seats = std::collections::BTreeMap::new(); + seats.insert( + seat.to_string(), + RawMarshalSeat { + history_values: values.to_vec(), + history_times: times.to_vec(), + }, + ); + Raw::MarshalData(RawMarshalData { + race: Some(RawMarshalRace { start_time }), + seats, + }) + } + + #[test] + fn done_transition_flags_a_marshal_request() { + let mut adapter = RotorHazardAdapter::new(); + adapter.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::RACING, + race_heat_id: Some(1), + })); + // No request before the heat ends. + assert!(!adapter.take_marshal_request()); + adapter.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::DONE, + race_heat_id: Some(1), + })); + // The DONE edge flags exactly one request; draining clears it (a re-sent DONE won't re-flag). + assert!( + adapter.take_marshal_request(), + "DONE flags a marshal request" + ); + assert!(!adapter.take_marshal_request(), "the flag is one-shot"); + // A re-sent DONE (no transition) does not re-flag. + adapter.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::DONE, + race_heat_id: Some(1), + })); + assert!( + !adapter.take_marshal_request(), + "re-sent DONE is not a transition" + ); + } + + /// Build a `gridfpv_signal` broadcast (D16, S2) with one node's dense slice + thresholds. `base` + /// is the accumulator index the slice starts at (0 = full snapshot/replace; `== len` = append). + fn grid_signal( + race_start: f64, + node: usize, + base: usize, + enter: f64, + exit: f64, + times: &[f64], + values: &[f64], + ) -> Raw { + Raw::GridSignal(RawGridSignal { + race_start: Some(race_start), + nodes: vec![RawGridSignalNode { + index: node, + base, + current_rssi: values.last().copied(), + enter_at: Some(enter), + exit_at: Some(exit), + history_values: values.to_vec(), + history_times: times.to_vec(), + }], + }) + } + + fn thresholds(events: &[Event]) -> Vec<&SignalThresholds> { + events + .iter() + .filter_map(|e| match e { + Event::SignalThresholds(t) => Some(t), + _ => None, + }) + .collect() + } + + #[test] + fn grid_signal_emits_dense_history_and_thresholds() { + let mut a = RotorHazardAdapter::with_id(AdapterId("rh".into())); + // start 10.0s; samples 10.1/10.2/10.3s -> 100k/200k/300k µs race-relative (like marshal data). + let events = a.translate(grid_signal( + 10.0, + 2, + 0, + 90.0, + 80.0, + &[10.1, 10.2, 10.3], + &[70.0, 150.0, 71.0], + )); + let hs = histories(&events); + assert_eq!(hs.len(), 1); + assert_eq!(hs[0].competitor, CompetitorRef("node-2".into())); + assert_eq!(hs[0].times, vec![100_000, 200_000, 300_000]); + assert_eq!(hs[0].rssi, vec![70, 150, 71]); + let th = thresholds(&events); + assert_eq!(th.len(), 1); + assert_eq!((th[0].enter, th[0].exit), (90, 80)); + assert_eq!(th[0].competitor, CompetitorRef("node-2".into())); + } + + #[test] + fn grid_signal_accumulates_incremental_slices() { + let mut a = RotorHazardAdapter::new(); + // First broadcast (base 0 = full snapshot): emits the dense history + thresholds. + let first = a.translate(grid_signal( + 0.0, + 0, + 0, + 90.0, + 80.0, + &[0.1, 0.2], + &[70.0, 150.0], + )); + assert_eq!( + histories(&first).len(), + 1, + "first snapshot emits a dense history" + ); + assert_eq!(histories(&first)[0].rssi, vec![70, 150]); + assert_eq!( + thresholds(&first).len(), + 1, + "first snapshot emits thresholds" + ); + + // An APPEND slice (base == current length 2) extends the accumulator; the emitted history is + // the FULL accumulated trace, and unchanged thresholds are not re-emitted. + let appended = a.translate(grid_signal(0.0, 0, 2, 90.0, 80.0, &[0.3], &[71.0])); + assert_eq!( + histories(&appended).len(), + 1, + "an append emits the grown trace" + ); + assert_eq!(histories(&appended)[0].rssi, vec![70, 150, 71]); + assert_eq!( + histories(&appended)[0].times, + vec![100_000, 200_000, 300_000] + ); + assert!( + thresholds(&appended).is_empty(), + "unchanged thresholds are not re-emitted" + ); + + // A redundant full snapshot identical to the accumulator (e.g. the final flush) is a no-op. + let resent = a.translate(grid_signal( + 0.0, + 0, + 0, + 90.0, + 80.0, + &[0.1, 0.2, 0.3], + &[70.0, 150.0, 71.0], + )); + assert!( + histories(&resent).is_empty(), + "an identical full snapshot emits nothing" + ); + + // An out-of-sync append (base != length, no replace) is skipped, not mis-appended. + let desync = a.translate(grid_signal(0.0, 0, 99, 90.0, 80.0, &[9.9], &[123.0])); + assert!( + histories(&desync).is_empty(), + "an out-of-sync slice is skipped" + ); + } + + #[test] + fn live_signal_suppresses_the_marshal_pull_on_done() { + let mut a = RotorHazardAdapter::new(); + a.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::RACING, + race_heat_id: Some(1), + })); + // The plugin pushes live signal during the race... + a.translate(grid_signal(0.0, 0, 0, 90.0, 80.0, &[0.1], &[150.0])); + a.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::DONE, + race_heat_id: Some(1), + })); + // ...so the dense trace is already in hand: the DONE edge must NOT request the post-race pull. + assert!( + !a.take_marshal_request(), + "live plugin signal makes the post-race save-then-pull redundant" + ); + } + + #[test] + fn grid_pass_emits_pass_seen_and_dedups_with_current_laps() { + let mut a = RotorHazardAdapter::with_id(AdapterId("rh".into())); + a.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::RACING, + race_heat_id: Some(1), + })); + // A native plugin pass for node 0, lap 1. + let evs = a.translate(Raw::GridPass(RawGridPass { + node_index: 0, + lap_number: 1, + lap_time_stamp: 1500.0, + peak_rssi: Some(180.0), + })); + let passes: Vec<_> = evs + .iter() + .filter_map(|e| { + if let Event::Pass(p) = e { + Some(p) + } else { + None + } + }) + .collect(); + assert_eq!(passes.len(), 1); + assert_eq!(passes[0].competitor, CompetitorRef("node-0".into())); + assert_eq!(passes[0].sequence, Some(1)); + assert_eq!(passes[0].at, SourceTime::from_micros(1_500_000)); + assert!(passes[0].signal.as_ref().unwrap().rssi_peak.is_some()); + assert!( + evs.iter() + .any(|e| matches!(e, Event::CompetitorSeen { .. })), + "a seat's first pass announces it" + ); + + // The current_laps snapshot re-reporting the SAME lap is deduped — no double Pass. + let snap = a.translate(snapshot(0, 0, vec![lap(1, 1500.0)])); + let dup = snap.iter().filter(|e| matches!(e, Event::Pass(_))).count(); + assert_eq!( + dup, 0, + "current_laps re-pass of the same (node, lap) is deduped" + ); + } + + #[test] + fn marshal_data_emits_dense_history_race_relative_micros() { + let mut adapter = RotorHazardAdapter::with_id(AdapterId("rh".into())); + // start_time 10.0s; samples at 10.1/10.2/10.3s -> 100k/200k/300k µs race-relative. + let events = adapter.translate(marshal_data( + 10.0, + 2, + &[10.1, 10.2, 10.3], + &[70.0, 150.0, 71.0], + )); + let hs = histories(&events); + assert_eq!(hs.len(), 1); + let h = hs[0]; + assert_eq!(h.competitor, CompetitorRef("node-2".into())); + assert_eq!(h.adapter, AdapterId("rh".into())); + assert_eq!(h.times, vec![100_000, 200_000, 300_000]); + assert_eq!(h.rssi, vec![70, 150, 71]); + } + + #[test] + fn marshal_data_skips_empty_or_mismatched_seats() { + let mut adapter = RotorHazardAdapter::new(); + // Empty history: no event. Mismatched lengths: take the common prefix. + let empty = adapter.translate(marshal_data(0.0, 0, &[], &[])); + assert!(histories(&empty).is_empty(), "an empty seat emits nothing"); + + let mut seats = std::collections::BTreeMap::new(); + seats.insert( + "0".to_string(), + RawMarshalSeat { + history_values: vec![70.0, 150.0, 71.0], + // One fewer time than values: the common prefix (2) is used. + history_times: vec![0.1, 0.2], + }, + ); + let mismatched = adapter.translate(Raw::MarshalData(RawMarshalData { + race: Some(RawMarshalRace { start_time: 0.0 }), + seats, + })); + let h = histories(&mismatched); + assert_eq!(h.len(), 1); + assert_eq!(h[0].times.len(), 2); + assert_eq!(h[0].rssi, vec![70, 150]); + } + + #[test] + fn marshal_data_clamps_negative_times_and_rssi_range() { + let mut adapter = RotorHazardAdapter::new(); + // A sample fractionally before start_time clamps to 0; RSSI clamps into u16. + let events = adapter.translate(marshal_data(5.0, 0, &[4.999_999, 5.5], &[-3.0, 70000.0])); + let h = histories(&events); + assert_eq!(h[0].times[0], 0, "a pre-start sample clamps to 0"); + assert_eq!(h[0].rssi, vec![0, u16::MAX]); + } + + #[test] + fn marshal_data_is_silent_without_signal_capability() { + let mut adapter = RotorHazardAdapter::new(); + adapter.signal_capture = false; + let events = adapter.translate(marshal_data(0.0, 0, &[0.1, 0.2], &[70.0, 150.0])); + assert!( + histories(&events).is_empty(), + "a non-signal source emits no dense history" + ); + } + + #[test] + fn race_list_queues_pilotrace_requests() { + let mut adapter = RotorHazardAdapter::new(); + let raw = r#"{"event":"race_list","heats":{"1":{"rounds":{"1":{"start_time":987741.3, + "pilotraces":[{"pilotrace_id":1,"node_index":0},{"pilotrace_id":2,"node_index":1}]}}}}}"#; + let parsed: Raw = serde_json::from_str(raw).expect("race_list parses"); + let events = adapter.translate(parsed); + // race_list emits no canonical events; it queues the per-pilotrace pulls. + assert!(events.is_empty()); + let reqs = adapter.take_pilotrace_requests(); + assert_eq!(reqs.len(), 2); + assert_eq!(reqs[0].pilotrace_id, 1); + assert_eq!(reqs[1].pilotrace_id, 2); + // Draining clears the queue. + assert!(adapter.take_pilotrace_requests().is_empty()); + } + + #[test] + fn race_details_emits_dense_history_relative_to_race_list_start() { + let mut adapter = RotorHazardAdapter::with_id(AdapterId("rh".into())); + // First the race_list teaches node-0's race start (987741.0s); then the per-pilotrace history. + let list = r#"{"event":"race_list","heats":{"1":{"rounds":{"1":{"start_time":987741.0, + "pilotraces":[{"pilotrace_id":1,"node_index":0}]}}}}}"#; + adapter.translate(serde_json::from_str::(list).unwrap()); + // history_times/values arrive as JSON-ENCODED STRINGS (this RH build's `json.dumps`). + let details = r#"{"event":"race_details","node_index":0, + "history_values":"[70, 150, 71]", + "history_times":"[987741.1, 987741.2, 987741.3]", + "enter_at":90,"exit_at":80}"#; + let events = adapter.translate(serde_json::from_str::(details).unwrap()); + let h = histories(&events); + assert_eq!(h.len(), 1); + assert_eq!(h[0].competitor, CompetitorRef("node-0".into())); + // Times are race-relative (minus the race_list start 987741.0): 0.1/0.2/0.3s -> µs. + assert_eq!(h[0].times, vec![100_000, 200_000, 300_000]); + assert_eq!(h[0].rssi, vec![70, 150, 71]); + // Thresholds are refreshed from enter_at/exit_at. + let t = events + .iter() + .find_map(|e| match e { + Event::SignalThresholds(t) => Some(t), + _ => None, + }) + .expect("thresholds emitted"); + assert_eq!((t.enter, t.exit), (90, 80)); + } + + #[test] + fn race_details_without_race_list_anchors_on_first_sample() { + // No prior race_list (so no known start): the trace anchors on its first sample (-> 0). + let mut adapter = RotorHazardAdapter::new(); + let details = r#"{"event":"race_details","node_index":2, + "history_values":[120, 121, 122], + "history_times":[5.5, 5.6, 5.7]}"#; + let events = adapter.translate(serde_json::from_str::(details).unwrap()); + let h = histories(&events); + assert_eq!(h[0].competitor, CompetitorRef("node-2".into())); + assert_eq!(h[0].times, vec![0, 100_000, 200_000]); + assert_eq!(h[0].rssi, vec![120, 121, 122]); + } + + #[test] + fn race_restart_clears_pending_marshal_state() { + // A new race must not carry stale pilotrace pulls / start times from the previous heat. + let mut adapter = RotorHazardAdapter::new(); + let list = r#"{"event":"race_list","heats":{"1":{"rounds":{"1":{"start_time":1.0, + "pilotraces":[{"pilotrace_id":9,"node_index":0}]}}}}}"#; + adapter.translate(serde_json::from_str::(list).unwrap()); + // A fresh RACING transition clears the queue. + adapter.translate(Raw::RaceStatus(RawRaceStatus { + race_status: race_status::RACING, + race_heat_id: Some(2), + })); + assert!(adapter.take_pilotrace_requests().is_empty()); + assert!(!adapter.take_marshal_request()); + } + #[test] fn raw_round_trips_through_json() { // The fixture envelope is stable: Raw -> JSON -> Raw is identity. diff --git a/crates/adapters/src/rotorhazard/transport.rs b/crates/adapters/src/rotorhazard/transport.rs index 207b43a..92f8c15 100644 --- a/crates/adapters/src/rotorhazard/transport.rs +++ b/crates/adapters/src/rotorhazard/transport.rs @@ -17,13 +17,31 @@ // rather than box every signature in this thin wrapper. #![allow(clippy::result_large_err)] +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// How long [`RotorHazardConnection::seat_heat`] waits for each `heat_data` / `pilot_data` response +/// before giving up on that step. Bounds the case where a quirky/slow RH never answers, so seating +/// never stalls staging — the caller then falls back to the practice-mode (no-current-heat) flow, +/// which still records via RH's `current_heat is HEAT_ID_NONE` pass-gate branch. +const SEAT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(3); + +/// The `gridfpv_*` wire-protocol version the Director speaks (D16, S1). Sent in the +/// `gridfpv_hello` probe so a plugin can negotiate; the Director treats a plugin whose +/// `protocol_version` differs as *incompatible* (the guided-install path then offers the matching +/// build). Bump only on a breaking change to the handshake/message shapes. +pub const DIRECTOR_PROTOCOL_VERSION: u32 = 1; use rust_socketio::client::Client; use rust_socketio::{ClientBuilder, Payload, RawClient}; use serde_json::json; -use super::{Raw, RawCurrentLaps, RawNodeData, RawPassRecord, RawRaceStatus, RotorHazardAdapter}; +use super::{ + Raw, RawCurrentLaps, RawEnterExitLevels, RawGridPass, RawGridSignal, RawHeatData, + RawMarshalData, RawNodeData, RawPassRecord, RawPilotData, RawRaceDetails, RawRaceList, + RawRaceStatus, RotorHazardAdapter, +}; use crate::Adapter; use gridfpv_events::Event; @@ -50,15 +68,104 @@ pub fn raw_from_socket(event: &str, payload: &Payload) -> Option { "node_data" => serde_json::from_value::(value) .ok() .map(Raw::NodeData), + "enter_and_exit_at_levels" => serde_json::from_value::(value) + .ok() + .map(Raw::EnterExitLevels), + "current_marshal_data" => serde_json::from_value::(value) + .ok() + .map(Raw::MarshalData), + "race_list" => serde_json::from_value::(value) + .ok() + .map(Raw::RaceList), + "race_details" => serde_json::from_value::(value) + .ok() + .map(Raw::RaceDetails), + "heat_data" => serde_json::from_value::(value) + .ok() + .map(Raw::HeatData), + "pilot_data" => serde_json::from_value::(value) + .ok() + .map(Raw::PilotData), + // The GridFPV plugin's live signal push (D16, Slice 2). Absent on a stock RH. + "gridfpv_signal" => serde_json::from_value::(value) + .ok() + .map(Raw::GridSignal), + // The GridFPV plugin's native per-node pass (D16, Slice 3). Absent on a stock RH. + "gridfpv_pass" => serde_json::from_value::(value) + .ok() + .map(Raw::GridPass), _ => None, } } +/// The GridFPV plugin's `gridfpv_hello_ack` payload — the in-process RH plugin's reply to +/// the Director's `gridfpv_hello` probe (RH plugin design D16, Slice 1). Present only when a +/// plugin-equipped RH answered the handshake; a stock RH never registers the handler, so the +/// probe simply times out (the Director then surfaces the guided install). Pure wire data — +/// the app layer maps it to its `PluginPresence` status. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PluginHello { + /// The `gridfpv_*` wire-protocol version (negotiated against the Director's supported range). + pub protocol_version: u32, + /// The plugin build's own version string (e.g. `"0.1.0"`). + #[serde(default)] + pub plugin_version: String, + /// The RHAPI version the plugin reports (e.g. `"1.4"`). + #[serde(default)] + pub rhapi_version: String, + /// Capabilities the plugin declares it implements (e.g. `["hello"]`; later `"live_signal"`, + /// `"clean_control"`, `"recalc"`). The Director keys transport decisions off these. + #[serde(default)] + pub capabilities: Vec, + /// The plugin's node/seat count. + #[serde(default)] + pub node_count: u32, +} + +/// Parse a `gridfpv_hello_ack` socket payload (a one-element array, like every RH emit) into a +/// [`PluginHello`]. `None` for a malformed/unexpected shape (treated as no answer). +fn parse_hello(payload: &Payload) -> Option { + let value = match payload { + Payload::Text(values) => values.first()?.clone(), + _ => return None, + }; + serde_json::from_value(value).ok() +} + /// A live connection to a RotorHazard server, translating its socket stream into /// canonical [`Event`]s. pub struct RotorHazardConnection { client: Client, events: Arc>>, + /// The adapter driving translation, held behind a handle so the **persistent** driver can + /// recover it on [`disconnect`](Self::disconnect) and reuse it across a reconnect (#105). The + /// adapter's dedup / `last_race_status` must survive a mid-race reconnect: a fresh adapter has an + /// empty dedup and would re-emit RotorHazard's re-sent `current_laps` snapshot as duplicate laps. + adapter: Arc>, + /// Liveness flag flipped to `false` by `rust_socketio`'s reserved `close`/`error` handlers when + /// the socket drops. With `.reconnect(false)` (see [`connect`](Self::connect)) a dropped link is + /// a real, final close — `rust_socketio` no longer silently buffers emits and auto-reconnects — + /// so the driver can read [`is_alive`](Self::is_alive) as the source of truth for a drop (#105). + alive: Arc, + /// The newest configured heat id learned from the most recent `heat_data` response (the highest + /// id, i.e. the freshest — the one `ensure_savable_heat` just added). The `heat_data` socket + /// handler stashes it here; the **driver thread** drains it via [`take_savable_heat`] and selects + /// it synchronously (`set_current_heat`) before staging, so the run is savable (the dense-history + /// precondition) without any emit-per-`heat_data` feedback loop on the socket callback. + savable_heat: Arc>>, + /// RotorHazard's **current race-format id**, learned from the `race_status` stream + /// (`emit_race_status`'s `race_format_id`, which arrives on connect and on every status change). + /// [`prepare_instant_start`](Self::prepare_instant_start) zeroes *this* format's staging delays + /// so `stage_race` transitions straight to RACING with no RH-side staging hold/tones — the + /// Grid-owns-all-timing model (#…): Grid's start procedure is the only delay; RH just records on + /// command. `None` until the first `race_status` carrying a format id is folded. + current_format: Arc>>, + /// The GridFPV plugin's handshake reply (`gridfpv_hello_ack`), once it arrives. The Director + /// emits `gridfpv_hello` on (re)connect; a plugin-equipped RH answers and the `gridfpv_hello_ack` + /// handler stashes the [`PluginHello`] here, which the driver reads via + /// [`wait_for_plugin`](Self::wait_for_plugin). Stays `None` against a stock RH (no handler + /// registered) — that absence is what drives the Director's guided-install prompt (D16, S1). + hello: Arc>>, } impl RotorHazardConnection { @@ -67,51 +174,480 @@ impl RotorHazardConnection { pub fn connect(url: &str, adapter: RotorHazardAdapter) -> Result { let adapter = Arc::new(Mutex::new(adapter)); let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + // Starts alive; flipped to `false` by the `close`/`error` reserved-event handlers below. + let alive = Arc::new(AtomicBool::new(true)); + // The newest savable heat id, stashed by the `heat_data` handler and drained by the driver + // (see the struct field). Starts empty (no heat learned yet). + let savable_heat: Arc>> = Arc::new(Mutex::new(None)); + // The current race-format id, learned from the `race_status` stream (see the struct field). + // Starts empty (no status folded yet); the first `race_status` on connect populates it. + let current_format: Arc>> = Arc::new(Mutex::new(None)); + // The GridFPV plugin handshake reply, stashed by the `gridfpv_hello_ack` handler below and + // read by the driver (see the struct field). Empty until/unless a plugin-equipped RH answers. + let hello: Arc>> = Arc::new(Mutex::new(None)); - // One handler per translated event: decode -> translate -> accumulate. + // `rust_socketio`'s reserved events: on a dropped socket the poll loop fires `error` + // (the engine.io read failed) and, on a clean disconnect packet, `close`. Either way the + // link is no longer usable, so flip `alive` to `false` — the truth the driver monitors. + let drop_handler = |alive: Arc| { + move |_payload: Payload, _client: RawClient| { + alive.store(false, Ordering::Relaxed); + } + }; + + // One handler per translated event: decode -> translate -> accumulate. After translating, if + // the adapter flagged a heat-end marshal-data request (set on the DONE transition, drained + // here so it fires once), emit RotorHazard's `current_race_marshal` on the socket the callback + // was handed. RotorHazard replies with `current_marshal_data` — the dense `history_values`/ + // `history_times` trace — which the `current_marshal_data` handler below feeds back through the + // same adapter as `SignalHistory`. Driving the request from the `race_status` callback keeps + // all wire IO in the transport while the trigger stays in the pure translator. let handler = |name: &'static str, adapter: Arc>, - sink: Arc>>| { - move |payload: Payload, _client: RawClient| { + sink: Arc>>, + savable_heat: Arc>>, + current_format: Arc>>| { + move |payload: Payload, client: RawClient| { + // Learn the current race-format id from the `race_status` stream (it carries + // `race_format_id`). `prepare_instant_start` zeroes that format's staging delays so + // `stage_race` reaches RACING with no RH-side staging hold (Grid owns all timing). + // Parsed straight off the payload here (independent of the translator's `Raw`) so the + // adapter's shape is untouched. + if name == "race_status" { + if let Payload::Text(values) = &payload { + if let Some(id) = values + .first() + .and_then(|v| v.get("race_format_id")) + .and_then(|v| v.as_i64()) + { + *current_format.lock().unwrap() = Some(id); + } + } + } if let Some(raw) = raw_from_socket(name, &payload) { - let translated = adapter.lock().unwrap().translate(raw); + let (translated, request_marshal, pilotrace_requests, heat_ids) = { + let mut a = adapter.lock().unwrap(); + let translated = a.translate(raw); + // Drain the heat-end intents: the one-shot marshal request (set on the DONE + // edge), any per-pilotrace pulls discovered from a `race_list`, and the + // configured heat ids learned from a `heat_data` (so a savable heat can be + // selected before staging — the dense-history precondition). + let request_marshal = a.take_marshal_request(); + let pilotrace_requests = a.take_pilotrace_requests(); + let heat_ids = a.take_heat_ids(); + (translated, request_marshal, pilotrace_requests, heat_ids) + }; if !translated.is_empty() { sink.lock().unwrap().extend(translated); } + // A `heat_data` response lists the configured heats: stash the highest (newest) id + // so the **driver thread** can select it as the current (savable) heat + // synchronously, before staging (see `RotorHazardConnection::take_savable_heat`). + // We do NOT emit `set_current_heat` from this socket callback: `heat_data` is + // broadcast on every heat mutation, and an emit-per-`heat_data` would feed back + // (set_current_heat -> heat/current-heat re-emits) and flood the link. The driver + // selects once, deterministically, on its own thread instead. + if let Some(&heat) = heat_ids.iter().max() { + if heat >= 0 { + savable_heat.lock().unwrap().replace(heat as u64); + } + } + if request_marshal { + // Heat just ended: pull the dense history. Two RotorHazard builds expose it + // differently, so drive both — whichever the server implements answers: + // • newer RH: the aggregate `current_race_marshal` -> `current_marshal_data`; + // • older RH: per-pilotrace — `save_laps` (persist the run), then request the + // saved-race tree (`race_list`) whose ids drive `get_pilotrace` below. + // All best-effort: a failed emit on a dropped link just leaves the coarse + // streamed trace, which the driver's reconnect path tolerates. + let _ = client.emit("current_race_marshal", Payload::Text(vec![])); + let _ = client.emit("save_laps", Payload::Text(vec![])); + let _ = client.emit("load_data", json!({ "load_types": ["race_list"] })); + } + // A `race_list` yields the per-pilotrace ids to pull; issue each `get_pilotrace` + // so its `race_details` (the dense history) folds back through this same adapter. + for req in pilotrace_requests { + let _ = client + .emit("get_pilotrace", json!({ "pilotrace_id": req.pilotrace_id })); + } } } }; + // RotorHazard timers are LAN devices; a box served over HTTPS will almost always carry a + // **self-signed** cert. Accept invalid certs/hostnames for the timer connection so a + // self-signed RH still works. This LAN-trust relaxation is scoped to the **timer adapter + // only** — it is explicitly NOT the posture for cloud/internet traffic, which must verify + // TLS properly (the cloud rule). Plain-HTTP RotorHazard — the common case — is unaffected + // (no handshake occurs). `rust_socketio` uses the same `.expect()` for its own connector; + // building one from flags performs no I/O and does not realistically fail. + let tls = native_tls::TlsConnector::builder() + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true) + .build() + .expect("build a relaxed TLS connector for the LAN RotorHazard timer"); + let client = ClientBuilder::new(url.to_string()) - // Resume after a dropped connection. On reconnect RotorHazard re-sends the - // full `current_laps` snapshot; the adapter's per-lap dedup makes that - // replay safe (no double-counted laps) — see the dedup module + the - // rh_signal snapshot-dedup assertion. - .reconnect(true) + .tls_config(tls) + // Do NOT let `rust_socketio` auto-reconnect (#105). With `.reconnect(true)` a dropped + // socket is invisible: the client buffers emits and returns `Ok` while silently + // reconnecting in the background, so `probe_liveness`'s emit never errors and a real + // drop is never detected. With `.reconnect(false)` a drop becomes a real, final close + // that fires the `close`/`error` reserved events below — and the *driver* owns + // reconnection (it has backoff and **reuses this adapter across reconnects**: + // `connect` takes it, [`disconnect`](Self::disconnect) returns it). On its reconnect + // RotorHazard re-sends the full `current_laps` snapshot; because the adapter's per-lap + // dedup persists across the reconnect, that replay is suppressed (no double-counted + // laps, #105) — see the dedup module + the rh_signal snapshot-dedup assertion. + .reconnect(false) + .on("error", drop_handler(alive.clone())) + .on("close", drop_handler(alive.clone())) .on( "race_status", - handler("race_status", adapter.clone(), events.clone()), + handler( + "race_status", + adapter.clone(), + events.clone(), + savable_heat.clone(), + current_format.clone(), + ), ) .on( "current_laps", - handler("current_laps", adapter.clone(), events.clone()), + handler( + "current_laps", + adapter.clone(), + events.clone(), + savable_heat.clone(), + current_format.clone(), + ), ) .on( "node_data", - handler("node_data", adapter.clone(), events.clone()), + handler( + "node_data", + adapter.clone(), + events.clone(), + savable_heat.clone(), + current_format.clone(), + ), ) .on( "pass_record", - handler("pass_record", adapter.clone(), events.clone()), + handler( + "pass_record", + adapter.clone(), + events.clone(), + savable_heat.clone(), + current_format.clone(), + ), + ) + .on( + "enter_and_exit_at_levels", + handler( + "enter_and_exit_at_levels", + adapter.clone(), + events.clone(), + savable_heat.clone(), + current_format.clone(), + ), + ) + .on( + "current_marshal_data", + handler( + "current_marshal_data", + adapter.clone(), + events.clone(), + savable_heat.clone(), + current_format.clone(), + ), + ) + .on( + "race_list", + handler( + "race_list", + adapter.clone(), + events.clone(), + savable_heat.clone(), + current_format.clone(), + ), + ) + .on( + "race_details", + handler( + "race_details", + adapter.clone(), + events.clone(), + savable_heat.clone(), + current_format.clone(), + ), ) + .on( + "heat_data", + handler( + "heat_data", + adapter.clone(), + events.clone(), + savable_heat.clone(), + current_format.clone(), + ), + ) + .on( + "pilot_data", + handler( + "pilot_data", + adapter.clone(), + events.clone(), + savable_heat.clone(), + current_format.clone(), + ), + ) + // The GridFPV plugin's live signal push (D16, S2): folds straight through the same + // translator as the RH-native signal events (→ SignalThresholds/SignalHistory). + .on( + "gridfpv_signal", + handler( + "gridfpv_signal", + adapter.clone(), + events.clone(), + savable_heat.clone(), + current_format.clone(), + ), + ) + // The GridFPV plugin's native per-node pass (D16, S3): folds to a Pass, deduped with + // the current_laps path. + .on( + "gridfpv_pass", + handler( + "gridfpv_pass", + adapter.clone(), + events.clone(), + savable_heat.clone(), + current_format.clone(), + ), + ) + // The GridFPV plugin's handshake reply (D16, S1): a plugin-equipped RH answers our + // `gridfpv_hello` (emitted below) with `gridfpv_hello_ack`. Stash it for the driver. + .on("gridfpv_hello_ack", { + let hello = hello.clone(); + move |payload: Payload, _client: RawClient| { + if let Some(parsed) = parse_hello(&payload) { + *hello.lock().expect("plugin-hello lock") = Some(parsed); + } + } + }) .connect()?; - // Warm initial state on (re)connect: ask RH to send current per-node RSSI so - // the signal-context cache is populated before the first pass. `current_laps` - // and `race_status` arrive via the normal snapshot stream. - let _ = client.emit("load_data", json!({ "load_types": ["node_data"] })); + // Warm initial state on (re)connect: ask RH to send current per-node RSSI, the enter/exit + // detection thresholds, and the current race status (so the **current format id** is learned + // early — `prepare_instant_start` needs it to zero that format's staging). `current_laps` + // also arrives via the normal snapshot stream. + let _ = client.emit( + "load_data", + json!({ "load_types": ["node_data", "enter_and_exit_at_levels", "race_status"] }), + ); + + // Probe for the GridFPV plugin (D16, S1): emit `gridfpv_hello` over the connection we just + // opened. A plugin-equipped RH replies with `gridfpv_hello_ack` (handled above); a stock RH + // has no handler, so nothing comes back and the driver's `wait_for_plugin` times out. We + // announce the Director's supported protocol version so the plugin can negotiate later. + let _ = client.emit( + "gridfpv_hello", + json!({ "protocol_version": DIRECTOR_PROTOCOL_VERSION }), + ); - Ok(Self { client, events }) + Ok(Self { + client, + events, + adapter, + alive, + savable_heat, + current_format, + hello, + }) + } + + /// Take (and clear) the newest savable heat id learned from a `heat_data` response, if any. + /// + /// The driver calls this after [`ensure_savable_heat`](Self::ensure_savable_heat) requested the + /// heat list: a `Some(id)` means the heat list arrived and `id` is the freshest heat to make + /// current (`set_current_heat`) before staging, so the run persists its dense history. `None` + /// until the `heat_data` response has been folded. + pub fn take_savable_heat(&self) -> Option { + self.savable_heat.lock().expect("savable-heat lock").take() + } + + /// Wait up to `timeout` for the GridFPV plugin's `gridfpv_hello_ack` (D16, S1), returning the + /// [`PluginHello`] if a plugin-equipped RH answered, or `None` if none did (a stock RH — the + /// guided-install case). Blocking poll (small sleeps): the driver calls this on its own thread + /// right after [`connect`](Self::connect), so it never stalls the async runtime. The + /// `gridfpv_hello` probe is emitted by `connect`, so by the time this is called the reply may + /// already be in; we poll rather than block on a channel to keep the transport lock-free. + pub fn wait_for_plugin(&self, timeout: Duration) -> Option { + let deadline = Instant::now() + timeout; + loop { + if let Some(hello) = self.hello.lock().expect("plugin-hello lock").clone() { + return Some(hello); + } + if Instant::now() >= deadline { + return None; + } + std::thread::sleep(Duration::from_millis(50)); + } + } + + /// **Seat a heat's bound pilots on RotorHazard nodes** so RH records *and* attributes passes for + /// each bound node — the laps-attribute fix. Without this, GridFPV's node→pilot binding lives only + /// in the Director's log; RH races a current heat with **no seated pilots**, and its pass gate + /// (`server.py` `do_pass_record_callback`: a pass on a node with `pilot_id == PILOT_ID_NONE` while + /// a heat is current is *dismissed* "Pilot not defined") rejects every crossing → zero laps even + /// with clear gate crossings. + /// + /// `seats` is the heat's `(node_index, callsign)` bind, one entry per **bound** node (unbound + /// nodes are simply left unseated — RH won't record there, which is correct). For each seat this: + /// 1. adds a fresh RH pilot (`add_pilot`) and learns its id (the highest from `pilot_data`), + /// 2. names it with the GridFPV callsign (`alter_pilot { callsign }`) so RH's own view + its + /// "Racing heat … pilots: …" log are right, + /// 3. assigns it to the heat's slot at that node (`alter_heat { heat, slot_id, pilot }`). + /// + /// Then it selects the heat as current (`set_current_heat`) so the seats take effect for the race. + /// + /// The heat is **freshly added** here (`add_heat`) and this is the heat the finish-time dense save + /// then reuses (it is already current + savable), so seating doubles as the savable-heat selection + /// — there is no separate empty heat. Returns the seated heat id (so the driver records it and the + /// finish path skips re-adding an empty one), or `None` if the seating could not complete (no + /// `heat_data`/`pilot_data` response within the bounded waits — the caller falls back to the + /// practice-mode flow, which still records via the `current_heat is HEAT_ID_NONE` gate branch). + /// + /// Runs **synchronously on the driver thread** (like the finish dance) so its emits stay ordered + /// and off the socket callback. Best-effort and bounded: a quirky/slow RH that never answers + /// `heat_data`/`pilot_data` times out rather than stalling staging. A failed emit on a dropped + /// socket surfaces as `Err` so the caller reconnects. + pub fn seat_heat(&self, seats: &[(u64, String)]) -> Result, rust_socketio::Error> { + if seats.is_empty() { + return Ok(None); + } + // Add a fresh heat and learn its per-node slot ids (the `HeatNode` PKs `alter_heat` targets). + self.adapter.lock().unwrap().take_heat_slots(); + self.client.emit("add_heat", Payload::Text(vec![]))?; + self.client + .emit("load_data", json!({ "load_types": ["heat_data"] }))?; + let Some((heat_id, node_to_slot)) = self.wait_for_heat_slots() else { + return Ok(None); + }; + + // Learn the current highest pilot id BEFORE creating any, so each `add_pilot` can be + // identified as "the new id strictly greater than the floor" rather than the bare max — RH + // also broadcasts `pilot_data` on an `alter_pilot` rename, so a stale broadcast carrying an + // *existing* (lower-or-equal) id must not be mistaken for the just-added pilot. + self.client + .emit("load_data", json!({ "load_types": ["pilot_data"] }))?; + // The current highest pilot id (0 if RH has no pilots yet); the next created pilot exceeds it. + let mut pilot_floor = self.wait_for_pilot_above(i64::MIN).unwrap_or(0); + + let mut seated_any = false; + for (node_index, callsign) in seats { + let Some(&slot_id) = node_to_slot.get(&(*node_index as usize)) else { + // The freshly-added heat has no slot for this node index (more bound nodes than RH + // nodes) — skip it; RH won't record there, which is the correct degradation. + continue; + }; + // Create a pilot and learn its id (the new id strictly above the running floor). + self.adapter.lock().unwrap().take_pilot_ids(); + self.client.emit("add_pilot", Payload::Text(vec![]))?; + self.client + .emit("load_data", json!({ "load_types": ["pilot_data"] }))?; + let Some(pilot_id) = self.wait_for_pilot_above(pilot_floor) else { + continue; + }; + pilot_floor = pilot_id; + // Name it with the GridFPV callsign so RH's own view + its staging log show the callsign. + self.client.emit( + "alter_pilot", + json!({ "pilot_id": pilot_id, "callsign": callsign }), + )?; + // Seat the pilot on the heat's node slot. + self.client.emit( + "alter_heat", + json!({ "heat": heat_id, "slot_id": slot_id, "pilot": pilot_id }), + )?; + seated_any = true; + } + + // Only make the heat current (and claim it as savable + seated) if at least one bound pilot + // was actually assigned. If nothing seated — every node slot missing, or every `add_pilot` + // timed out — selecting this empty heat as current would make RH dismiss every crossing + // ("Pilot not defined") → zero laps, strictly worse than NOT selecting it. Returning `None` + // leaves the connection in practice mode (no current heat), where RH still records via its + // `current_heat is HEAT_ID_NONE` gate branch, and the finish path adds its own savable heat. + if !seated_any { + return Ok(None); + } + // Make the seated heat current so the seats take effect (and so it is the savable heat the + // finish-time dense pull reuses — no separate empty heat). + self.client + .emit("set_current_heat", json!({ "heat": heat_id }))?; + Ok(Some(heat_id as u64)) + } + + /// Wait (bounded) for a `heat_data` response after [`seat_heat`]'s `add_heat`, returning the + /// **freshest** heat (highest id) and its `node_index → slot_id` map — but only once that map is + /// **non-empty** (RH may broadcast a `heat_data` for a freshly-added heat before its `HeatNode` + /// rows carry a `node_index`; accepting an empty map would seat nobody yet still mark the heat + /// savable). `None` on timeout. + fn wait_for_heat_slots(&self) -> Option<(i64, std::collections::HashMap)> { + let deadline = Instant::now() + SEAT_RESPONSE_TIMEOUT; + loop { + { + let mut a = self.adapter.lock().unwrap(); + let slots = a.take_heat_slots(); + // Pick the freshest heat (highest id) that actually carries node→slot mappings. + if let Some((&heat_id, node_to_slot)) = slots + .iter() + .filter(|(_, m)| !m.is_empty()) + .max_by_key(|(id, _)| **id) + { + return Some((heat_id, node_to_slot.clone())); + } + } + if Instant::now() >= deadline || !self.is_alive() { + return None; + } + std::thread::sleep(Duration::from_millis(50)); + } + } + + /// Wait (bounded) for a `pilot_data` response carrying a pilot id **strictly greater than** + /// `floor`, returning that id (the highest such) — used to identify the pilot a preceding + /// `add_pilot` just created. Passing `i64::MIN` returns the current highest id (the seating + /// floor). `None` on timeout (no id above `floor` arrived). + fn wait_for_pilot_above(&self, floor: i64) -> Option { + let deadline = Instant::now() + SEAT_RESPONSE_TIMEOUT; + loop { + { + let mut a = self.adapter.lock().unwrap(); + let id = a + .take_pilot_ids() + .into_iter() + .filter(|id| *id > floor) + .max(); + if let Some(id) = id { + return Some(id); + } + } + if Instant::now() >= deadline || !self.is_alive() { + return None; + } + std::thread::sleep(Duration::from_millis(50)); + } + } + + /// Whether the socket is still live (#105). The reserved `close`/`error` handlers flip this to + /// `false` the moment `rust_socketio` observes the connection drop; with `.reconnect(false)` + /// that is final, so this is the driver's source of truth for detecting a drop — unlike an + /// emit, which a buffering client could still report as `Ok`. + pub fn is_alive(&self) -> bool { + self.alive.load(Ordering::Relaxed) } /// Take everything translated since the last call. @@ -126,24 +662,199 @@ impl RotorHazardConnection { self.client.emit("stage_race", Payload::Text(vec![])) } + /// **Prepare RotorHazard for an instant start** — the Grid-owns-all-timing model: GridFPV's own + /// start procedure (the randomized Armed hold + the start tone) is the *only* delay; RH must just + /// begin recording the instant Grid says go, with **no RH-side staging hold or tones**. + /// + /// Stock RotorHazard formats ship with a multi-second staging sequence — staging *tones* plus a + /// fixed/random *start delay* (`start_delay_min_ms`/`start_delay_max_ms`, typically 1–2 s) — that + /// `on_stage_race` runs as its own STAGING→RACING countdown ("Staging new race, format: … → + /// tones → Race started"). That ran *on top of* Grid's start procedure (two competing start + /// sequences — the root of the staging-race-eats-laps bug the `STAGE_RESET_SETTLE` band-aid + /// papered over). This zeroes the **current** format's staging fields so `stage_race`'s + /// `staging_total_ms` is 0 and RH transitions straight to RACING: + /// * `staging_fixed_tones = 0`, `staging_delay_tones = 0` — no staging tones; + /// * `start_delay_min_ms = 0`, `start_delay_max_ms = 0` — no fixed/random staging delay; + /// * `unlimited_time = 1` — RH never auto-expires the race; **Grid owns the stop**, not RH's + /// race-format timer. + /// + /// The only residual is RotorHazard's fixed `RACE_START_DELAY_EXTRA_SECS` prestage (a + /// `Config.GENERAL` value, ~0.9 s by default, with no socket setter), which is *constant* and so + /// **does not affect lap-time correctness**: RH timestamps every pass relative to its own race + /// start, and Grid derives lap times as pass-to-pass deltas on that clock, so a constant prestage + /// offset cancels out. (If a deployment needs RH RACING to coincide exactly with Grid's tone, set + /// `RACE_START_DELAY_EXTRA_SECS = 0` in the timer's config — outside this socket API.) + /// + /// Targets the current format learned from the `race_status` stream; re-applies it as current so + /// `RaceContext.race.format` reflects the zeroed staging. **Must be emitted while RH is READY** — + /// `alter_race_format`/`set_race_format` are rejected during an active race — so the bridge calls + /// this at **Stage** (pre-Armed, pre-go), not at the start instant. A no-op (best-effort `Ok`) if + /// no current format id has been learned yet. Idempotent: re-zeroing an already-zeroed format is + /// harmless. + pub fn prepare_instant_start(&self) -> Result<(), rust_socketio::Error> { + let Some(format_id) = *self.current_format.lock().expect("current-format lock") else { + // No format id known yet (no `race_status` folded): cannot target a format. Best-effort + // no-op — staging then keeps whatever the format ships, which the reset/stage flow still + // tolerates (the start is just not guaranteed instant on this connection). + return Ok(()); + }; + self.client.emit( + "alter_race_format", + json!({ + "format_id": format_id, + "staging_fixed_tones": 0, + "staging_delay_tones": 0, + "start_delay_min_ms": 0, + "start_delay_max_ms": 0, + "unlimited_time": 1, + }), + )?; + // Re-select the format as current so `RaceContext.race.format` picks up the zeroed staging + // (altering the row alone does not refresh the in-memory current-format object on every RH). + self.client + .emit("set_race_format", json!({ "race_format": format_id })) + } + /// Inject a simulated pass on `node` (0-based) — driving helper for tests. pub fn simulate_lap(&self, node: u64) -> Result<(), rust_socketio::Error> { self.client.emit("simulate_lap", json!({ "node": node })) } + /// **Tune** `node` (0-based) to `frequency` MHz (race redesign Slice 4a) — the engine allocates + /// the channel, the adapter applies it (RE §7.3). Emits RotorHazard's `set_frequency` handler + /// (`{ node, frequency }`); the server retunes that node's receiver. Best-effort: a failed emit + /// on a dropped socket surfaces as an `Err` the caller logs. + pub fn set_frequency(&self, node: u64, frequency: u16) -> Result<(), rust_socketio::Error> { + self.client.emit( + "set_frequency", + json!({ "node": node, "frequency": frequency }), + ) + } + + /// Set RotorHazard's **minimum lap time** (general setting `MIN_LAP_TIME`, in **seconds**) — + /// a driving helper so the sim/test harness does not trip RH's "Pass record under lap + /// minimum" filter. + /// + /// RotorHazard defaults `MIN_LAP_TIME` to **10s** and logs `Pass record under lap minimum (10)` + /// for any crossing closer than that to the previous one — which the test harness's rapid + /// `simulate_lap` injections (and short-lap sim CSVs) routinely are, so RH spams the warning. + /// Emitting RotorHazard's `set_option` handler with `{ option: "MIN_LAP_TIME", value: "" }` + /// persists the setting server-side; passing `0` disables the minimum entirely so every + /// short sim lap records cleanly. Best-effort (a failed emit on a dropped socket is the + /// caller's to log); intended for the disposable test RH only, never a production timer. + pub fn set_min_lap_time(&self, seconds: u64) -> Result<(), rust_socketio::Error> { + self.client.emit( + "set_option", + json!({ "option": "MIN_LAP_TIME", "value": seconds.to_string() }), + ) + } + /// Stop the current race — driving helper for tests. pub fn stop_race(&self) -> Result<(), rust_socketio::Error> { self.client.emit("stop_race", Payload::Text(vec![])) } + /// Re-request the per-node enter/exit detection thresholds (`load_data` / + /// `enter_and_exit_at_levels`) — a driving helper so a test can re-capture thresholds after + /// draining the connect-time burst. + pub fn request_thresholds(&self) -> Result<(), rust_socketio::Error> { + self.client.emit( + "load_data", + json!({ "load_types": ["enter_and_exit_at_levels"] }), + ) + } + + /// Add a heat (`add_heat`, 0-arg) — a driving helper for the dense-marshal-data test so a saved + /// heat exists to select (RotorHazard's per-pilotrace marshal path needs a saved race). + pub fn add_heat(&self) -> Result<(), rust_socketio::Error> { + self.client.emit("add_heat", Payload::Text(vec![])) + } + + /// Persist the just-finished race (`save_laps`, 0-arg) so its per-pilotrace history is written to + /// the DB and becomes pullable via `get_pilotrace` — a driving helper for the dense-history test. + pub fn save_laps(&self) -> Result<(), rust_socketio::Error> { + self.client.emit("save_laps", Payload::Text(vec![])) + } + + /// Ensure a **savable current heat** exists on RotorHazard so the next run persists its dense + /// per-tick RSSI history (the marshaling Slice 1 / path-2 precondition). + /// + /// RotorHazard only writes a run's `history_values`/`history_times` (the dense trace its marshal + /// page reviews, pulled via `current_marshal_data` / `get_pilotrace`) when a heat is current — + /// `on_save_laps` and `emit_race_marshal_data` both no-op while `current_heat == HEAT_ID_NONE`, + /// the default in practice mode. The production staging path drives RH through + /// `stop_race`/`discard_laps`/`stage_race` but never selects a heat, so without this the dense + /// pull always comes back empty and only the coarse streamed [`SignalChunk`]s survive. + /// + /// This adds a fresh heat (`add_heat`) and **requests** `heat_data`; the `heat_data` handler + /// stashes the newest heat id, which the **driver** then reads via [`take_savable_heat`] and + /// selects synchronously (`set_current_heat`) before staging. Selection is deliberately NOT done + /// in the socket callback: `heat_data` is broadcast on every heat mutation, so an emit-per-event + /// would feed back and flood the link (the regression that dropped a staging connection). + /// Best-effort: a failed emit on a dropped link just leaves the coarse trace, which the reconnect + /// path tolerates. Adding a heat each call is acceptable for the dockerized test rig; a future + /// refinement could reuse an existing empty heat instead of always adding one. + pub fn ensure_savable_heat(&self) -> Result<(), rust_socketio::Error> { + self.client.emit("add_heat", Payload::Text(vec![]))?; + self.client + .emit("load_data", json!({ "load_types": ["heat_data"] })) + } + + /// Select RotorHazard's **current heat** (`set_current_heat`, `{ heat: }`) — a driving + /// helper for the dense-marshal-data test. + /// + /// RotorHazard's `emit_race_marshal_data` only answers when a **saved heat** is current + /// (`current_heat != HEAT_ID_NONE`); the default practice mode has no heat, so the test selects + /// one before the race so the post-race dense history can be pulled. Best-effort. + pub fn set_current_heat(&self, heat: u64) -> Result<(), rust_socketio::Error> { + self.client + .emit("set_current_heat", json!({ "heat": heat })) + } + + /// Request RotorHazard's dense **post-race marshal data** (`current_race_marshal`) — the + /// request-driven `current_marshal_data` with each node's `history_values`/`history_times`. + /// + /// In normal operation the adapter auto-requests this on the heat-end (`DONE`) transition (see + /// the `race_status` handler); this explicit helper lets a test pull it on demand after staging a + /// race down, so the dense-history capture can be asserted deterministically. RotorHazard only + /// answers while the race is `DONE` (`emit_race_marshal_data` returns early otherwise). + pub fn request_marshal_data(&self) -> Result<(), rust_socketio::Error> { + self.client + .emit("current_race_marshal", Payload::Text(vec![])) + } + /// Discard the current race's laps, returning RotorHazard to a READY state — /// driving helper so a test can stage cleanly regardless of prior state. pub fn discard_laps(&self) -> Result<(), rust_socketio::Error> { self.client.emit("discard_laps", Payload::Text(vec![])) } - /// Disconnect from the server. - pub fn disconnect(self) -> Result<(), rust_socketio::Error> { - self.client.disconnect() + /// Probe that the socket is still live without driving the race (#105). Re-requests the current + /// per-node data — a cheap, idempotent server query the adapter's dedup makes side-effect-free — + /// so a quiet-but-healthy idle link confirms it is up, while a dropped socket surfaces an emit + /// error the caller can treat as a disconnect. Used by the persistent connection's monitor. + pub fn probe_liveness(&self) -> Result<(), rust_socketio::Error> { + self.client + .emit("load_data", json!({ "load_types": ["node_data"] })) + } + + /// Disconnect from the server, **returning the adapter** so the persistent driver can carry its + /// dedup / `last_race_status` into the next connection (#105). Reusing the adapter is what keeps + /// a mid-race reconnect from double-counting: RotorHazard re-sends the full `current_laps` + /// snapshot on the new socket, and only a dedup that already saw those laps suppresses the + /// replay. The socket disconnect itself is best-effort — even if it errors the adapter (the + /// state we care about) is recovered. The `Arc>` is uniquely held here once the socket + /// is torn down (the `rust_socketio` handler clones are dropped with the client), so unwrapping + /// it back to an owned adapter cannot fail in practice; on the off chance it is still shared we + /// fall back to cloning the inner adapter (its dedup/state clone is cheap and lossless). + pub fn disconnect(self) -> RotorHazardAdapter { + self.client.disconnect().ok(); + // Drop the client first so its registered socket handlers (which hold `adapter` clones) are + // released, leaving this connection the sole owner of the adapter handle. + drop(self.client); + match Arc::try_unwrap(self.adapter) { + Ok(mutex) => mutex.into_inner().expect("adapter mutex poisoned"), + Err(shared) => shared.lock().expect("adapter mutex poisoned").clone(), + } } } diff --git a/crates/adapters/src/velocidrone.rs b/crates/adapters/src/velocidrone.rs index 1090649..41684f4 100644 --- a/crates/adapters/src/velocidrone.rs +++ b/crates/adapters/src/velocidrone.rs @@ -354,6 +354,8 @@ impl VelocidroneAdapter { sequence: None, gate: map_gate(data.gate), signal: None, + // The adapter doesn't know the heat; the bridge sink stamps it at append. + heat: None, })); } } @@ -530,6 +532,7 @@ mod tests { sequence: None, gate: GateIndex::LAP, signal: None, + heat: None, }), ] ); @@ -655,6 +658,7 @@ mod tests { sequence: None, gate, signal: None, + heat: None, }) }; let seen = |competitor: &str| Event::CompetitorSeen { diff --git a/crates/adapters/tests/rh_live.rs b/crates/adapters/tests/rh_live.rs index e2c8a60..4564a95 100644 --- a/crates/adapters/tests/rh_live.rs +++ b/crates/adapters/tests/rh_live.rs @@ -33,14 +33,27 @@ fn event_kind(e: &Event) -> &'static str { Event::SessionStarted { .. } => "SessionStarted", Event::SessionEnded { .. } => "SessionEnded", Event::CompetitorSeen { .. } => "CompetitorSeen", + Event::CompetitorRegistered { .. } => "CompetitorRegistered", Event::Pass(_) => "Pass", + Event::SignalChunk(_) => "SignalChunk", + Event::SignalThresholds(_) => "SignalThresholds", + Event::SignalHistory(_) => "SignalHistory", Event::HeatScheduled { .. } => "HeatScheduled", Event::HeatStateChanged { .. } => "HeatStateChanged", + Event::CurrentHeatSelected { .. } => "CurrentHeatSelected", + Event::HeatStarting { .. } => "HeatStarting", + Event::HeatFinalizing { .. } => "HeatFinalizing", Event::DetectionVoided { .. } => "DetectionVoided", Event::LapInserted { .. } => "LapInserted", Event::LapAdjusted { .. } => "LapAdjusted", + Event::LapSplit { .. } => "LapSplit", + Event::LapThrownOut { .. } => "LapThrownOut", Event::HeatVoided { .. } => "HeatVoided", Event::PenaltyApplied { .. } => "PenaltyApplied", + Event::ProtestFiled { .. } => "ProtestFiled", + Event::ProtestResolved { .. } => "ProtestResolved", + Event::RulingReversed { .. } => "RulingReversed", + Event::RoundFieldDrawn { .. } => "RoundFieldDrawn", } } @@ -78,6 +91,11 @@ fn live_rotorhazard_race_translates_to_laps() { // Let the connection settle and the server register us before driving it. std::thread::sleep(Duration::from_secs(2)); + // Disable RH's lap minimum for the sim: our rapid `simulate_lap` injections are far closer + // together than RH's 10s default `MIN_LAP_TIME`, which otherwise logs "Pass record under lap + // minimum (10)" for every short lap. `0` lets every sim lap record cleanly. + conn.set_min_lap_time(0).ok(); + // Reset to a clean READY state (a prior DONE race blocks staging), then ignore // any events that reset produced — we only assert on the fresh race below. conn.stop_race().ok(); @@ -118,7 +136,57 @@ fn live_rotorhazard_race_translates_to_laps() { conn.stop_race().expect("emit stop_race"); std::thread::sleep(Duration::from_millis(800)); events.extend(conn.events()); - conn.disconnect().ok(); + + // Second heat on the SAME persistent connection/adapter (#105 cross-heat regression). + // RotorHazard resets lap_number to 0 at each race start; without resetting the per-lap + // dedup on the RACING transition, heat 2's laps would collide with heat 1's and be + // suppressed (zero laps ingested past the first heat). Reset, re-stage, and assert + // heat 2 produces its own fresh laps. + conn.discard_laps().expect("emit discard_laps (heat 2)"); + std::thread::sleep(Duration::from_secs(2)); + let _ = conn.events(); + let mut heat2: Vec = Vec::new(); + + conn.stage_race().expect("emit stage_race (heat 2)"); + let started2 = wait_until(&conn, &mut heat2, Duration::from_secs(20), |evs| { + evs.iter() + .any(|e| matches!(e, Event::SessionStarted { .. })) + }); + assert!(started2, "heat 2 never reached RACING (no SessionStarted)"); + + for node in [0u64, 0, 1, 0] { + conn.simulate_lap(node).expect("emit simulate_lap (heat 2)"); + std::thread::sleep(Duration::from_millis(1200)); + heat2.extend(conn.events()); + } + let got_laps2 = wait_until(&conn, &mut heat2, Duration::from_secs(10), |evs| { + lap_list(evs) + .competitors + .iter() + .any(|c| c.competitor.competitor.0 == "node-0" && c.lap_count() >= 1) + }); + + conn.stop_race().expect("emit stop_race (heat 2)"); + std::thread::sleep(Duration::from_millis(800)); + heat2.extend(conn.events()); + conn.disconnect(); + + assert!( + got_laps2, + "heat 2 ingested no completed lap for node-0 \ + (cross-heat dedup regression: lap_number reset collided with heat 1)" + ); + let laps2 = lap_list(&heat2); + let node0_h2 = laps2 + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "node-0") + .expect("node-0 present in heat 2 lap list"); + assert!( + node0_h2.lap_count() >= 1, + "expected >= 1 lap for node-0 in heat 2, got {}", + node0_h2.lap_count() + ); // The live stream produced lifecycle + competitor sightings + real laps. assert!( @@ -156,3 +224,92 @@ fn live_rotorhazard_race_translates_to_laps() { node0.laps ); } + +/// Mid-race reconnect must not double-count (#105). Drives a real race, accumulates laps, then +/// **disconnects mid-race and reconnects reusing the same adapter** (the persisted-adapter fix: +/// `disconnect` returns the adapter, the next `connect` takes it). RotorHazard re-sends the full +/// in-progress `current_laps` snapshot on the new socket; because the carried adapter's dedup +/// already holds those laps, the lap count after the reconnect must equal the count before it (no +/// duplicates). Pre-fix, a fresh adapter on reconnect re-emitted every in-progress lap and the lap +/// projection (no sequence dedup) turned them into duplicate laps. +#[test] +#[ignore = "requires a running dockerized RotorHazard (docker/rotorhazard/)"] +fn mid_race_reconnect_does_not_double_count_laps() { + let rh = RhContainer::start(PORT + 1, "0.5", &[]); + let conn = RotorHazardConnection::connect(rh.url(), RotorHazardAdapter::new()) + .expect("connect to RotorHazard"); + + let mut events: Vec = Vec::new(); + std::thread::sleep(Duration::from_secs(2)); + + // Disable RH's lap minimum for the sim (see the first test) so the short `simulate_lap` + // crossings below don't trip "Pass record under lap minimum (10)". + conn.set_min_lap_time(0).ok(); + + conn.stop_race().ok(); + conn.discard_laps().expect("emit discard_laps"); + std::thread::sleep(Duration::from_secs(2)); + let _ = conn.events(); + + conn.stage_race().expect("emit stage_race"); + assert!( + wait_until(&conn, &mut events, Duration::from_secs(20), |evs| { + evs.iter() + .any(|e| matches!(e, Event::SessionStarted { .. })) + }), + "race never reached RACING (no SessionStarted)" + ); + + // Drive several crossings on node 0 so there are in-progress laps to replay. + for node in [0u64, 0, 0, 0] { + conn.simulate_lap(node).expect("emit simulate_lap"); + std::thread::sleep(Duration::from_millis(1200)); + events.extend(conn.events()); + } + assert!( + wait_until(&conn, &mut events, Duration::from_secs(10), |evs| { + lap_list(evs) + .competitors + .iter() + .any(|c| c.competitor.competitor.0 == "node-0" && c.lap_count() >= 1) + }), + "node-0 never produced a completed lap before the reconnect" + ); + let laps_before = lap_list(&events) + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "node-0") + .map(|c| c.lap_count()) + .unwrap_or(0); + assert!(laps_before >= 1, "need at least one lap before reconnect"); + + // Simulate a mid-race drop+reconnect: recover the adapter from `disconnect` and feed it into a + // fresh socket. The race keeps RUNNING server-side, so RH replays the in-progress snapshot. + let adapter = conn.disconnect(); + std::thread::sleep(Duration::from_millis(500)); + let conn = RotorHazardConnection::connect(rh.url(), adapter) + .expect("reconnect to RotorHazard reusing the persisted adapter"); + + // Drain the replayed snapshot the reconnect triggers; give it time to arrive. + let mut after: Vec = Vec::new(); + wait_until(&conn, &mut after, Duration::from_secs(5), |_| false); + events.extend(after); + + conn.stop_race().ok(); + std::thread::sleep(Duration::from_millis(800)); + events.extend(conn.events()); + conn.disconnect(); + + let laps_after = lap_list(&events) + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "node-0") + .map(|c| c.lap_count()) + .unwrap_or(0); + assert_eq!( + laps_after, laps_before, + "mid-race reconnect double-counted laps: {laps_before} before, {laps_after} after \ + (the replayed in-progress snapshot was not deduped — the persisted-adapter fix regressed)" + ); + println!("live RH reconnect: node-0 stable at {laps_after} lap(s) across the reconnect"); +} diff --git a/crates/adapters/tests/rh_signal.rs b/crates/adapters/tests/rh_signal.rs index e2892c2..a686cda 100644 --- a/crates/adapters/tests/rh_signal.rs +++ b/crates/adapters/tests/rh_signal.rs @@ -19,7 +19,7 @@ use std::time::{Duration, Instant}; use gridfpv_adapters::rotorhazard::RotorHazardAdapter; use gridfpv_adapters::rotorhazard::transport::RotorHazardConnection; use gridfpv_events::Event; -use gridfpv_projection::{LapList, lap_list}; +use gridfpv_projection::{LapList, lap_list, signal_trace}; use gridfpv_testkit::{NodeCsv, RhContainer, node_csv}; const PORT: u16 = 5031; @@ -76,6 +76,7 @@ fn emulated_signal_multi_node_race() { ticks_per_lap: 6, peak_rssi: 150, baseline_rssi: 70, + seed: 0, }), ), ( @@ -84,6 +85,7 @@ fn emulated_signal_multi_node_race() { ticks_per_lap: 10, peak_rssi: 120, baseline_rssi: 60, + seed: 0, }), ), ]; @@ -95,12 +97,22 @@ fn emulated_signal_multi_node_race() { let mut events: Vec = Vec::new(); std::thread::sleep(Duration::from_secs(2)); - // Clean state, then start a race; the mock CSVs drive the laps (no simulate_lap). - conn.stop_race().ok(); - conn.discard_laps().expect("discard_laps"); + // Disable RH's lap minimum for the sim: the mock CSVs drive short laps that would otherwise + // trip RH's 10s default `MIN_LAP_TIME` ("Pass record under lap minimum (10)"). `0` lets every + // emulated-signal crossing record cleanly. + conn.set_min_lap_time(0).ok(); + + // Grid owns all timing: prepare RH for an instant start (zero its staging hold/tones + reset to + // READY), exactly as the Director does at the heat's **Staged** transition — well before "go". + conn.prepare_instant_start().expect("prepare_instant_start"); std::thread::sleep(Duration::from_secs(2)); let _ = conn.events(); + // "Go": a single `stage_race` (no reset, no settle) starts RH recording. RH must reach RACING + // **without an RH-side staging hold/tone** — only the fixed prestage — so we assert the + // transition is prompt (≤ ~1.5s) rather than the multi-second staging countdown a stock format + // would run. This is the no-RH-staging guarantee. + let go = Instant::now(); conn.stage_race().expect("stage_race"); assert!( wait_until(&conn, &mut events, Duration::from_secs(20), |evs| { @@ -109,6 +121,13 @@ fn emulated_signal_multi_node_race() { }), "race never reached RACING" ); + let to_racing = go.elapsed(); + assert!( + to_racing <= Duration::from_millis(1_500), + "RH must start racing promptly on command with no staging hold/tone (Grid owns the delay); \ + took {to_racing:?} — a stock multi-second staging countdown would be far longer" + ); + println!("instant-start: RH reached RACING {to_racing:?} after the go (no RH staging hold)"); // Let the emulated signals produce laps on both nodes. let both_have_laps = wait_until(&conn, &mut events, Duration::from_secs(25), |evs| { @@ -120,7 +139,7 @@ fn emulated_signal_multi_node_race() { conn.stop_race().ok(); std::thread::sleep(Duration::from_millis(800)); events.extend(conn.events()); - conn.disconnect().ok(); + conn.disconnect(); assert!( both_have_laps, @@ -149,6 +168,31 @@ fn emulated_signal_multi_node_race() { "lap durations must be positive" ); + // --- lap-time correctness on Grid's clock (Grid owns all timing) --- + // node-0 flies `ticks_per_lap: 6` at `TICK = 0.1s` ⇒ ~0.6s per lap. Lap durations are pass-to- + // pass deltas on RH's race clock, which Grid maps directly — and because they are *differences*, + // RH's fixed prestage offset cancels, so the durations must land near the emulated 0.6s/lap + // regardless of how long after the go RH actually reached RACING. A generous band absorbs the + // mock pipeline's jitter while still proving the mapping is correct (not offset/stretched). + const TICK_MS: i64 = 100; // TICK = "0.1" seconds + let node0_expected_us = 6 * TICK_MS * 1_000; // 600_000 µs + for lap in &node0.laps { + let d = lap.duration_micros; + assert!( + (node0_expected_us / 2..=node0_expected_us * 2).contains(&d), + "node-0 lap {} duration {}µs must map near the emulated ~{}µs/lap on Grid's clock \ + (the prestage offset cancels in pass-to-pass deltas); all: {:?}", + lap.number, + d, + node0_expected_us, + node0 + .laps + .iter() + .map(|l| l.duration_micros) + .collect::>() + ); + } + // --- dedup: lap numbers strictly increasing (a re-sent snapshot must not dup) --- for node in ["node-0", "node-1"] { let nums: Vec = laps_for(&laps, node) @@ -190,3 +234,316 @@ fn emulated_signal_multi_node_race() { node1.lap_count() ); } + +/// The **fidelity gate** (marshaling Slice 1): capture the RSSI trace + enter/exit thresholds from a +/// dockerized RotorHazard driven by an emulated node-output stream (`NodeCsv`), and assert the +/// captured trace matches the RSSI values that CSV actually reports — no quantization or dropout +/// within what RotorHazard exposes live. +/// +/// The CSV now drives a **realistic gate-pass profile** (a baseline floor + a Gaussian bell on each +/// crossing + small deterministic noise; see `gridfpv_testkit::pass_value`), not a square step. +/// RotorHazard re-emits `node_data` on its heartbeat while racing, so the adapter captures one trace +/// sample per emit. The fidelity invariant adapts to the model: every captured sample must lie in +/// the model's band `[1, peak + noise]` (a plausible RSSI the CSV could report — no quantization or +/// out-of-range value), and the captured trace must reach the **peak band** at least once (the +/// crossing peak is faithfully captured, so the marshaling graph sees the real crest). Thresholds +/// (RH's `enter_at`/`exit_at`) are captured from `enter_and_exit_at_levels`. +/// +/// NB: this is the value RH *streams* live (one aggregate `node_peak_rssi` per emit), which is the +/// documented fidelity bound — coarser than the detector's internal per-tick history (that lives in +/// the request-driven `current_marshal_data`, which a live translator does not subscribe to). The +/// thing for the maintainer to confirm on real hardware is whether this streamed cadence is dense +/// enough for the evidence layer, or whether the request-driven history must be pulled at heat end. +#[test] +#[ignore = "requires Docker (spins up dockerized RotorHazard with emulated signals)"] +fn captured_trace_matches_emitted_csv_samples() { + const PEAK: i32 = 150; + const BASELINE: i32 = 70; + // One node flying the realistic gate-pass profile: the per-tick streamed `node_peak` rises from + // `baseline_rssi` to a bell crest near `peak_rssi` at each crossing, with a few units of noise. + let csvs = vec![( + 0usize, + node_csv(&NodeCsv { + ticks_per_lap: 48, + peak_rssi: PEAK, + baseline_rssi: BASELINE, + seed: 0, + }), + )]; + + let rh = RhContainer::start(PORT + 1, TICK, &csvs); + let conn = RotorHazardConnection::connect(rh.url(), RotorHazardAdapter::new()) + .expect("connect to RotorHazard"); + + let mut events: Vec = Vec::new(); + std::thread::sleep(Duration::from_secs(2)); + conn.set_min_lap_time(0).ok(); + conn.stop_race().ok(); + conn.discard_laps().expect("discard_laps"); + std::thread::sleep(Duration::from_secs(2)); + let _ = conn.events(); + + conn.stage_race().expect("stage_race"); + assert!( + wait_until(&conn, &mut events, Duration::from_secs(20), |evs| { + evs.iter() + .any(|e| matches!(e, Event::SessionStarted { .. })) + }), + "race never reached RACING" + ); + + // Re-request thresholds AFTER racing starts: the RACING transition clears the adapter's + // threshold dedup, and the connect-time capture was discarded by the drain above, so re-ask now + // so the (unchanged-value) `SignalThresholds` are re-emitted and captured into `events`. + conn.request_thresholds().ok(); + + // Let the heartbeat stream a run of node_data ticks so the trace accumulates samples. The + // coarse `node_data` stream is sparse (a handful of samples per heat); the dense per-tick shape + // is asserted by `dense_marshal_history_supersedes_coarse_stream`. Here we just need enough + // coarse samples to check each is a faithful in-band value of the realistic profile. + let captured_enough = wait_until(&conn, &mut events, Duration::from_secs(20), |evs| { + signal_trace(evs) + .competitor(&gridfpv_projection::CompetitorKey { + adapter: gridfpv_events::AdapterId("rotorhazard".into()), + competitor: gridfpv_events::CompetitorRef("node-0".into()), + }) + .map(|t| t.samples.len() >= 5) + .unwrap_or(false) + }); + + conn.stop_race().ok(); + std::thread::sleep(Duration::from_millis(800)); + events.extend(conn.events()); + conn.disconnect(); + + assert!(captured_enough, "node-0 never accumulated a trace"); + + let view = signal_trace(&events); + let trace = view + .competitor(&gridfpv_projection::CompetitorKey { + adapter: gridfpv_events::AdapterId("rotorhazard".into()), + competitor: gridfpv_events::CompetitorRef("node-0".into()), + }) + .expect("node-0 trace present"); + + // Fidelity within the realistic model: every captured sample lies in the band the CSV could + // report — `[baseline - noise, peak + noise]` — with no quantization or out-of-range value. + // (Lap-to-lap variation + per-sample noise mean the faithful invariant is band-membership, not a + // single constant — `captured == emitted` *within the model*. The dense path asserts the full + // rise→peak→fall shape; the sparse coarse stream may not span a whole pass.) + const NOISE_HEADROOM: i32 = 10; // a few units of per-sample + peak-variation slack. + let lo = (BASELINE - NOISE_HEADROOM).max(1) as u16; + let hi = (PEAK + NOISE_HEADROOM) as u16; + assert!(!trace.samples.is_empty(), "trace has samples"); + for &s in &trace.samples { + assert!( + (lo..=hi).contains(&s), + "captured sample {s} outside the model band [{lo}, {hi}] (baseline/peak ± noise); \ + full trace: {:?}", + trace.samples + ); + } + let max = trace.samples.iter().copied().max().unwrap(); + let min = trace.samples.iter().copied().min().unwrap(); + // The capture cadence is positive and the trace is time-anchored near the race start. Under the + // live plugin path the first dense sample carries its *actual* race-relative time (a small + // positive offset — the first peak/nadir entry after the start), not the marshal-pull path's + // zeroed origin, so assert "anchored within the opening second" rather than exactly 0. + assert!(trace.period_micros > 0); + let from = trace.from.expect("trace is time-anchored").micros; + assert!( + (0..1_000_000).contains(&from), + "the trace anchors near the race start (got {from} µs)" + ); + + // Thresholds: RotorHazard's enter/exit levels were captured from `enter_and_exit_at_levels`. + // (The mock profile carries the stock defaults; we assert they were captured, not their exact + // value, since the profile default can vary by RH build.) + assert!( + trace.enter.is_some() && trace.exit.is_some(), + "enter/exit thresholds must be captured, got enter={:?} exit={:?}", + trace.enter, + trace.exit + ); + + println!( + "fidelity: node-0 captured {} samples (bell profile: min={min} max={max}, band [1,{}]); \ + thresholds enter={:?} exit={:?}", + trace.samples.len(), + PEAK + NOISE_HEADROOM, + trace.enter, + trace.exit + ); +} + +/// The **dense-history** gate (RH path 2 — `current_marshal_data`): after a heat ends, the adapter +/// pulls RotorHazard's request-driven dense marshal data and the `signal_trace` projection prefers +/// it over the coarse streamed samples. This asserts the dense trace carries **strictly more** +/// samples than the coarse streaming count captured live — the full-fidelity upgrade. +/// +/// Crucially this drives the dense path through the **production save precondition** +/// `conn.ensure_savable_heat()` (add a heat + select it as current) — exactly what the live +/// Director's staging loop now does before a heat — rather than poking `add_heat`/`set_current_heat` +/// by hand. RotorHazard only persists a run's dense history for a saved current heat, so this proves +/// the normal flow (not a bespoke test setup) activates path-2. +/// +/// Flow: drive an emulated node stream into a real dockerized RH, count the **coarse** streamed +/// samples accumulated while racing, finish the heat (which auto-triggers `current_race_marshal` / +/// `save_laps` -> `race_list` -> `get_pilotrace`), then re-fold and assert the now-dense trace has +/// many more samples (RH's per-tick history vs. one sample per `node_data` heartbeat). +#[test] +#[ignore = "requires Docker (spins up dockerized RotorHazard with emulated signals)"] +fn dense_marshal_history_supersedes_coarse_stream() { + const PEAK: i32 = 150; + const BASELINE: i32 = 70; + let csvs = vec![( + 0usize, + node_csv(&NodeCsv { + ticks_per_lap: 6, + peak_rssi: PEAK, + baseline_rssi: BASELINE, + seed: 0, + }), + )]; + + let rh = RhContainer::start(PORT + 2, TICK, &csvs); + let conn = RotorHazardConnection::connect(rh.url(), RotorHazardAdapter::new()) + .expect("connect to RotorHazard"); + + let key = gridfpv_projection::CompetitorKey { + adapter: gridfpv_events::AdapterId("rotorhazard".into()), + competitor: gridfpv_events::CompetitorRef("node-0".into()), + }; + + let mut events: Vec = Vec::new(); + std::thread::sleep(Duration::from_secs(2)); + conn.set_min_lap_time(0).ok(); + conn.stop_race().ok(); + conn.discard_laps().expect("discard_laps"); + // RotorHazard only persists (and thus exposes) a race's dense history for a SAVED heat — its + // `current_heat` is None in default practice mode, where `save_laps` is a no-op. Drive the same + // production precondition the live Director's staging loop now runs: `ensure_savable_heat` adds a + // heat and requests the heat list; the `heat_data` handler stashes the newest id, which we then + // select synchronously (exactly as the driver thread does). After this the post-race dense + // history is pullable through the normal flow. + conn.ensure_savable_heat().expect("ensure_savable_heat"); + let heat = { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let _ = conn.events(); + if let Some(h) = conn.take_savable_heat() { + break Some(h); + } + if Instant::now() >= deadline { + break None; + } + std::thread::sleep(Duration::from_millis(100)); + } + } + .expect("RotorHazard returned a savable heat id"); + conn.set_current_heat(heat).expect("set_current_heat"); + std::thread::sleep(Duration::from_secs(2)); + let _ = conn.events(); + + conn.stage_race().expect("stage_race"); + assert!( + wait_until(&conn, &mut events, Duration::from_secs(20), |evs| { + evs.iter() + .any(|e| matches!(e, Event::SessionStarted { .. })) + }), + "race never reached RACING" + ); + + // Let a run of samples accumulate while racing. (Coarse `node_data` chunks; with the GridFPV + // plugin mounted the live dense history is already superseding by the time we measure.) + let captured_enough = wait_until(&conn, &mut events, Duration::from_secs(20), |evs| { + signal_trace(evs) + .competitor(&key) + .map(|t| t.samples.len() >= 5) + .unwrap_or(false) + }); + assert!(captured_enough, "node-0 never accumulated a trace"); + + // The sample count mid-race, before the heat is stopped. + let coarse_samples = signal_trace(&events) + .competitor(&key) + .map(|t| t.samples.len()) + .unwrap_or(0); + + // S2 split: with the GridFPV plugin (the path `cargo xtask live` exercises) the dense history + // is pushed **live** over `gridfpv_signal` and supersedes the coarse stream *during* the race — + // the post-race save-then-pull is suppressed. Without it (stock RH fallback, which S3 deletes) + // the DONE edge drives the pull. Either way a dense `SignalHistory` results. + let plugin_live = std::env::var_os("GRIDFPV_RH_PLUGIN").is_some(); + let got_dense = if plugin_live { + // The live dense history should arrive while the heat is still running (no pull). + let live = wait_until(&conn, &mut events, Duration::from_secs(20), |evs| { + evs.iter().any(|e| matches!(e, Event::SignalHistory(_))) + }); + conn.stop_race().ok(); + live + } else { + // The DONE transition auto-triggers the marshal pull (`current_race_marshal`, then + // `save_laps` + `race_list` → `get_pilotrace` — whichever the server implements answers). + conn.stop_race().ok(); + wait_until(&conn, &mut events, Duration::from_secs(20), |evs| { + evs.iter().any(|e| matches!(e, Event::SignalHistory(_))) + }) + }; + + events.extend(conn.events()); + conn.disconnect(); + + assert!( + got_dense, + "no dense SignalHistory produced (coarse samples were {coarse_samples}, plugin_live={plugin_live})" + ); + + let dense_samples = signal_trace(&events) + .competitor(&key) + .map(|t| t.samples.len()) + .expect("node-0 dense trace"); + + if plugin_live { + // The plugin delivered the dense trace live (without the pull); assert it's a real trace. + assert!( + dense_samples >= 5, + "the live dense trace should carry real samples; got {dense_samples}" + ); + } else { + // The post-race pull yields a denser trace than the coarse stream. + assert!( + dense_samples > coarse_samples, + "dense history must carry MORE samples than the coarse stream: dense={dense_samples} \ + coarse={coarse_samples}" + ); + } + + // Eyeball artifact (run with --nocapture): a sparkline of the captured dense trace, exactly the + // shape the marshaling graph / `cargo xtask rh-mock dump` renders. Under the realistic model + // this is a noisy rise→peak→fall bell per pass, not a square wave. + let dense = signal_trace(&events).competitor(&key).cloned().unwrap(); + println!( + "dense marshal history: coarse stream = {coarse_samples} samples, dense history = \ + {dense_samples} samples (full-fidelity upgrade)\n trace: {}\n rssi : min={} max={}", + sparkline(&dense.samples), + dense.samples.iter().min().copied().unwrap_or(0), + dense.samples.iter().max().copied().unwrap_or(0), + ); +} + +/// A compact ASCII sparkline of RSSI samples (matches `cargo xtask rh-mock dump`), so a heat's +/// captured trace can be eyeballed from the live test output. +fn sparkline(samples: &[u16]) -> String { + if samples.is_empty() { + return String::new(); + } + const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let min = *samples.iter().min().unwrap() as i64; + let max = *samples.iter().max().unwrap() as i64; + let span = (max - min).max(1); + samples + .iter() + .map(|&s| BARS[((s as i64 - min) * 7 / span) as usize]) + .collect() +} diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index ad3b467..36c17b1 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gridfpv-app" -version = "0.1.0" +version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true @@ -10,11 +10,77 @@ rust-version.workspace = true name = "gridfpv" path = "src/main.rs" +[features] +# `live` compiles in the **real RotorHazard adapter** (the feature-gated Socket.IO transport) +# so a selected `Rotorhazard { url }` timer actually connects and feeds its passes into the +# event log (#65, #73). It pulls `gridfpv-adapters/live`, which pulls a TLS stack +# (native-tls, OpenSSL-vendored on Linux). +# +# `live` is a **DEFAULT feature**: the product's whole job is talking to real timers, and a +# default-off transport shipped a Director that could never leave "Configured" — it silently +# neutered both a dev deploy (2026-07-03, a lost test session) and the desktop release +# artifact (src-tauri depends on gridfpv-app without naming `live`). The openssl-vendored +# compile is a cache-warm one-time cost; correctness of the shipped binary wins. A slim, +# OpenSSL-free Mock/sim-only build remains available with `--no-default-features`. +default = ["live"] +live = ["dep:gridfpv-adapters", "gridfpv-adapters/live"] + +# `embed-assets` bakes the built RD console SPA (`frontend/apps/rd-console/dist`) **into the +# binary** at compile time (via `rust-embed`), so the Director serves the frontend from memory +# with **no external assets folder** beside the executable. This is how the native desktop +# (`gridfpv-desktop`) build becomes a single self-contained file. +# +# It is **OFF by default**: the team's hosted dev loop +# (`cargo build -p gridfpv-app --features live`, then serve a `GRIDFPV_ASSETS` dist from disk) +# must keep working byte-for-byte — rebuild the dist without rebuilding the binary — so the +# default build serves from the filesystem exactly as before and `cargo xtask ci` (default +# features) never needs a built dist at compile time. The dist must exist at compile time +# ONLY when this feature is enabled (the Tauri / release-builds workflow builds the frontend +# first, so that holds). +embed-assets = ["dep:rust-embed", "dep:mime_guess"] + [dependencies] gridfpv-events.workspace = true gridfpv-storage.workspace = true gridfpv-projection.workspace = true +# The race engine: the runtime clock (heat-lifecycle Slice 2) reads the heat FSM vocabulary +# (`GraceWindow`) and the pure win-condition completion predicate (`scoring::race_end_reached`) to +# drive the auto start/completion transitions. Pure Rust — no network/TLS, so the default Director +# build stays openssl-free. +gridfpv-engine.workspace = true +# Only compiled in under `--features live` (the real RotorHazard adapter + its TLS stack); the +# default build never pulls it (and so never pulls openssl). Also a dev-dependency below for the +# default-feature tests that use the pure translator without the live transport. +gridfpv-adapters = { workspace = true, optional = true } + +# The Director server: `main.rs` opens the log, builds `server::router(state)`, and +# serves the protocol API + the built RD console SPA over axum. `tokio` runs the async +# runtime; `tower-http` adds `ServeDir` (SPA fallback) + permissive CORS (so the Windows +# Tauri app, a different origin, can call the API/WS). axum matches the server's 0.8. +# NO adapters / `live` feature here — server + storage + axum + tower-http + tokio only, +# so the Director binary never pulls openssl. +gridfpv-server.workspace = true +axum = "0.8" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] } +tower-http = { version = "0.6", features = ["fs", "cors", "trace"] } + +# Only compiled in under `--features embed-assets`: `rust-embed` bakes the built RD console +# `dist/` into the binary at compile time, and `mime_guess` resolves each embedded file's +# Content-Type by extension. The default build pulls neither — it serves from the filesystem +# via `tower-http`'s `ServeDir` exactly as before, so the hosted dev loop is unchanged. +rust-embed = { version = "8", optional = true } +mime_guess = { version = "2", optional = true } [dev-dependencies] serde_json.workspace = true gridfpv-adapters.workspace = true +# `gridfpv-engine` is a regular dependency above (the runtime clock uses it), which covers the +# tests too — so no dev-dependency entry is needed. +# The dockerized-RotorHazard harness for the `live` Director RH-connect e2e +# (`tests/rh_connect_live.rs`): spins up + tears down a disposable RH container. +gridfpv-testkit.workspace = true +# The Director integration test (#13) drives the served router with no real network via +# `tower::ServiceExt::oneshot`, collecting the response body to assert on it. +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/crates/app/src/director.rs b/crates/app/src/director.rs new file mode 100644 index 0000000..80d8b34 --- /dev/null +++ b/crates/app/src/director.rs @@ -0,0 +1,396 @@ +//! The GridFPV **Director server** wiring (#13, v0.4 Director wiring). +//! +//! This is the app-level glue that turns the protocol [`server`] crate into a runnable +//! Director: it opens the one append-only event log, builds the protocol router over it, +//! serves the built RD console as a static SPA, and applies a permissive CORS layer so a +//! different-origin client (the Windows Tauri RD app) can call the API and open the WS. +//! +//! The **live-timer pipeline is deliberately not here** — this commit only wires the app +//! so a race *can* be served; feeding real timer passes into the log (the RH adapter → +//! engine → `AppState::append`) is the next step (see the crate roadmap / #13 follow-up). +//! +//! # Layout (why a builder, not just `main`) +//! +//! [`build_app`] assembles the full [`axum::Router`] — protocol routes + static SPA + +//! CORS — from an [`AppState`] and a resolved [`Config`]. Keeping it a pure function (no +//! binding, no process exit) lets the Director integration test (`tests/director.rs`) +//! drive the exact same router over an in-memory log via `tower::ServiceExt::oneshot`, +//! with no real socket and no dependency on a built frontend `dist/`. + +use std::future::Future; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; + +use axum::Router; +use axum::http::StatusCode; +#[cfg(not(feature = "embed-assets"))] +use axum::response::{Html, IntoResponse, Response}; +use gridfpv_events::AdapterId; +use gridfpv_server::app::{router, smart_fallback}; +use gridfpv_server::events::EventRegistry; +use tower_http::cors::CorsLayer; +#[cfg(not(feature = "embed-assets"))] +use tower_http::services::ServeDir; + +use crate::source::{SIM_ADAPTER, SourceConfig, spawn_presence_reconciler, spawn_registry_bridge}; + +/// The default listen address: every interface on port 8080, so the RD console and the +/// LAN spectators reach the Director without extra config. +pub const DEFAULT_ADDR: &str = "0.0.0.0:8080"; + +/// Resolved Director configuration, read from the environment with sane defaults. +/// +/// See [`Config::from_env`] for the env vars; the defaults make `gridfpv` runnable with +/// no configuration at all (in-memory log, bundled console). +#[derive(Debug, Clone)] +pub struct Config { + /// The socket address to bind (`GRIDFPV_ADDR`, default [`DEFAULT_ADDR`]). + pub addr: SocketAddr, + /// Where **persistent events' SQLite files** live (`GRIDFPV_DATA_DIR`) — one file per + /// created event (issue #72). `None` ⇒ no data dir configured: the built-in Practice + /// event is always in-memory, and any *created* event falls back to an in-memory log + /// (non-durable, fresh every start). `Some(dir)` ⇒ created events persist there. + pub data_dir: Option, + /// The directory of the built RD console SPA to serve (`GRIDFPV_ASSETS`); defaults to + /// the repo's `frontend/apps/rd-console/dist`. May not exist — the server still serves + /// the API and logs a warning (see [`build_app`]). + pub assets: PathBuf, +} + +impl Config { + /// Read the Director config from the environment, applying defaults. + /// + /// - `GRIDFPV_ADDR` — listen address (default `0.0.0.0:8080`). + /// - `GRIDFPV_DATA_DIR` — directory for persistent events' SQLite files (one per created + /// event, #72); unset ⇒ created events are in-memory (non-durable). The deprecated + /// `GRIDFPV_DB` (a single flat log path) is read as a *fallback* — its parent directory + /// is used as the data dir — so an old config still persists *somewhere*, though the + /// single-flat-log model it named is gone. + /// - `GRIDFPV_ASSETS` — RD console `dist/` directory; unset ⇒ the repo's + /// `frontend/apps/rd-console/dist`, resolved relative to the workspace root. + pub fn from_env() -> Result { + let addr = match std::env::var("GRIDFPV_ADDR") { + Ok(value) => value.parse().map_err(|e| { + format!("GRIDFPV_ADDR ({value:?}) is not a valid socket address: {e}") + })?, + Err(_) => DEFAULT_ADDR + .parse() + .expect("DEFAULT_ADDR is a valid address"), + }; + + let data_dir = match std::env::var("GRIDFPV_DATA_DIR") { + Ok(value) if !value.trim().is_empty() => Some(PathBuf::from(value)), + _ => match std::env::var("GRIDFPV_DB") { + // Back-compat: a `GRIDFPV_DB=` resolves its parent dir as the data dir. + Ok(value) if !value.trim().is_empty() => PathBuf::from(value) + .parent() + .map(Path::to_path_buf) + .filter(|p| !p.as_os_str().is_empty()), + _ => None, + }, + }; + + let assets = match std::env::var("GRIDFPV_ASSETS") { + Ok(value) if !value.trim().is_empty() => PathBuf::from(value), + _ => default_assets_dir(), + }; + + Ok(Self { + addr, + data_dir, + assets, + }) + } +} + +/// What [`run_director`] reports back to its caller once the listener is bound, before +/// it starts serving: the resolved bind address and whether the control path is gated. +/// +/// The Director binary uses this to print its startup banner; the Tauri app uses +/// `bound.port()` to learn the ephemeral port it should point the window at. +#[derive(Debug, Clone)] +pub struct DirectorReady { + /// The address the listener actually bound to. With a `:0` request this carries the + /// OS-assigned ephemeral port — the only way to learn it. + pub bound: SocketAddr, + /// The registered RD token, if `GRIDFPV_RD_TOKEN` configured one; `None` ⇒ control is + /// open (full-trust, safe on loopback). + pub rd_token: Option, + /// A one-line description of the active lap source (for the startup banner). + pub source_desc: String, +} + +/// Run the GridFPV Director server to completion: open the event registry, register an +/// optional RD token, spawn the built-in lap-source bridge + presence reconciler, build the +/// router, bind `addr`, hand the resolved [`DirectorReady`] to `on_ready`, then serve until +/// `shutdown` resolves. +/// +/// This is the **single reusable Director entry point**, shared by: +/// - the `gridfpv` binary (`main.rs`), which passes the env-resolved [`Config`] address, the +/// process Ctrl-C signal as `shutdown`, and an `on_ready` that prints the startup banner — +/// so the binary's behavior is unchanged; and +/// - the Tauri native app, which passes a per-user app-data dir, a `127.0.0.1:0` address +/// (ephemeral free port on loopback ⇒ no auth per the auth model), and an `on_ready` that +/// records `bound.port()` so it can point the window at `http://127.0.0.1:`. +/// +/// The token / bridge / presence wiring matches what `serve()` did inline before this was +/// extracted, so both callers get identical Director behavior. +/// +/// `addr` is the requested bind address (use a `:0` port for an OS-assigned ephemeral one). +/// `data_dir` is where created events' SQLite files live (`None` ⇒ in-memory, non-durable). +/// `assets` is the built RD-console `dist/` to serve as the SPA. `on_ready` is invoked once, +/// after binding, with the resolved [`DirectorReady`]. `shutdown` is awaited to trigger a +/// graceful stop. +pub async fn run_director( + addr: SocketAddr, + data_dir: Option, + assets: PathBuf, + on_ready: R, + shutdown: S, +) -> Result<(), Box> +where + R: FnOnce(&DirectorReady), + S: Future + Send + 'static, +{ + // Events are first-class containers (#72): the built-in Practice event is always present + // (in-memory); created events persist as a SQLite file under `data_dir` when set. + let registry = EventRegistry::new(data_dir)?; + + // Control auth is full-trust (open) by default (#72, Slice 1b): register an RD token only + // when `GRIDFPV_RD_TOKEN` is set to a non-blank value. On loopback (the Tauri app) this is + // unset, so control is open — matching the loopback-no-auth model. + let tokens = registry.tokens(); + let rd_token = match std::env::var("GRIDFPV_RD_TOKEN") { + Ok(value) if tokens.register_rd_token(&value) => Some(value), + _ => None, + }; + + // The built-in lap source (default `sim`) + the per-event control→source bridge: each + // event gets a bridge feeding sim passes into ITS log when a heat goes `Running`. Plus the + // sim auto-presence reconciler (race redesign Slice 1a). Both run until the process exits. + let source = SourceConfig::from_env(); + let source_desc = source.describe(); + let _bridge = + spawn_registry_bridge(registry.clone(), source, AdapterId(SIM_ADAPTER.to_string())); + let _presence = spawn_presence_reconciler(registry.clone()); + + let app = build_app(registry, &assets); + + let listener = tokio::net::TcpListener::bind(addr).await?; + let bound = listener.local_addr()?; + + on_ready(&DirectorReady { + bound, + rd_token, + source_desc, + }); + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown) + .await?; + Ok(()) +} + +/// The default RD console assets directory: `frontend/apps/rd-console/dist` under the +/// workspace root. +/// +/// The workspace root is two levels above this crate's manifest dir +/// (`/crates/app`), pinned at compile time via `CARGO_MANIFEST_DIR` so the default +/// resolves regardless of the process's working directory. +pub fn default_assets_dir() -> PathBuf { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() // /crates + .and_then(Path::parent) // + .unwrap_or(manifest_dir); + workspace_root.join("frontend/apps/rd-console/dist") +} + +/// Build the full Director [`Router`]: the protocol API, the static RD-console SPA, and a +/// permissive CORS layer — all over the shared [`AppState`]. +/// +/// The protocol router ([`server::app::router`]) is mounted first so its routes (`/health`, +/// `/snapshot/...`, `/stream`, the control surface) take precedence. A +/// [`ServeDir`](tower_http::services::ServeDir) then serves `assets` as the SPA root with a +/// **fallback to `index.html`** so client-side routes (deep links into the console) resolve +/// to the SPA shell instead of 404ing — wrapped in +/// [`smart_fallback`](gridfpv_server::app::smart_fallback) (#64) so a *mistyped API* path +/// (a wrong `/snapshot/...`, `/control/...`, `/auth/...`) returns a typed `ProtocolError` +/// 404 instead of the SPA shell, while genuine client-side routes still resolve to it. +/// Finally [`CorsLayer::permissive`] is applied so a different-origin client (the Tauri RD +/// app) can call the API and upgrade the WS. +/// +/// If `assets` does not exist the static service is *still* mounted (so the binary runs +/// without a prior `npm run build`); requests for `/` then yield a 404 from `ServeDir` +/// while the API stays fully functional. Callers should warn when the dir is missing +/// ([`build_app`] does not log — [`crate::director::asset_status`] reports it for `main`). +/// +/// # Embedded assets (`--features embed-assets`) +/// +/// When the `embed-assets` feature is enabled the SPA is served from **assets baked into +/// the binary** at compile time (see [`embedded`]) rather than from the filesystem — the +/// `assets` path is then ignored, so the produced binary is self-contained and needs no +/// external dist folder beside it. This is how the native desktop (`gridfpv-desktop`) build +/// becomes a single portable file. The default build (feature off) serves from `assets` on +/// disk exactly as before, keeping the hosted dev loop's rebuild-dist-only workflow intact. +pub fn build_app(registry: EventRegistry, assets: &Path) -> Router { + router(registry) + // Anything the protocol router does not handle falls through to `smart_fallback`: + // a mistyped API path → a typed `ProtocolError` 404 (#64), any other path → the SPA. + .fallback_service(smart_fallback(spa_service(assets))) + // Permissive CORS so the cross-origin Tauri RD app can reach the API + WS. + .layer(CorsLayer::permissive()) +} + +/// Build the SPA-serving service the Director composes into its `smart_fallback`. +/// +/// Default build: a [`ServeDir`] over the filesystem `assets`, with an `index.html` fallback +/// for client-side routes. The `assets` dir is read per-request, so a `npm run build` mid-run +/// is picked up without rebuilding the binary — the hosted dev loop relies on this. +#[cfg(not(feature = "embed-assets"))] +fn spa_service(assets: &Path) -> ServeDir { + // SPA serving: serve files out of `assets`. Any path that does not match a real file + // (a client-side route like `/heats/q-1/live`) falls back to the SPA shell + // `index.html` so deep links resolve to the app, not a 404. The fallback is an axum + // handler (rather than `ServeDir::not_found_service`) so it reliably returns the shell + // for *any* unmatched path, including nested ones, and a clear 404 when the console + // has not been built yet. + let index_html = assets.join("index.html"); + ServeDir::new(assets).fallback(spa_fallback(index_html)) +} + +/// Build the SPA-serving service from **embedded assets** (`--features embed-assets`). +/// +/// The `assets` filesystem path is ignored — every file is baked into the binary +/// (see [`embedded`]), so the produced binary self-serves the console with no external dist. +#[cfg(feature = "embed-assets")] +fn spa_service(_assets: &Path) -> Router { + embedded::router() +} + +/// Build the SPA-shell fallback service: a handler that returns the contents of +/// `index_html` (the client-side router takes over from there), or a 404 if the console +/// has not been built. Read per-request so a `npm run build` mid-run is picked up. +#[cfg(not(feature = "embed-assets"))] +fn spa_fallback(index_html: PathBuf) -> axum::routing::MethodRouter { + axum::routing::get(move || { + let index_html = index_html.clone(); + async move { + match tokio::fs::read_to_string(&index_html).await { + Ok(body) => Html(body).into_response(), + Err(_) => spa_unbuilt_response(), + } + } + }) +} + +/// The response served when a client route is requested but the RD console has not been +/// built (no `index.html`): the [`UNBUILT_FALLBACK_STATUS`] with a short explanation. +#[cfg(not(feature = "embed-assets"))] +fn spa_unbuilt_response() -> Response { + ( + UNBUILT_FALLBACK_STATUS, + "the RD console has not been built — run `cd frontend && npm run build` (the protocol API is still available)", + ) + .into_response() +} + +/// Serve the RD console SPA from assets **embedded into the binary** at compile time +/// (`--features embed-assets`). +/// +/// `rust-embed` bakes the entire `frontend/apps/rd-console/dist` tree into the executable, so +/// the Director self-serves the console with no external dist folder beside it — the basis of +/// the single-file portable / native-desktop build. A request for an embedded file is served +/// with its guessed Content-Type; any other path falls back to the embedded `index.html` so +/// client-side deep links resolve to the SPA shell (mirroring the filesystem `ServeDir` +/// behavior). The embed path is resolved relative to this crate's manifest dir, so the dist +/// must exist at compile time **only** when this feature is on (the Tauri / release-builds +/// workflow builds the frontend first). +#[cfg(feature = "embed-assets")] +mod embedded { + use axum::Router; + use axum::body::Body; + use axum::http::{StatusCode, Uri, header}; + use axum::response::{IntoResponse, Response}; + use axum::routing::get; + use rust_embed::RustEmbed; + + /// The built RD console `dist/` baked into the binary at compile time. The path is + /// relative to this crate's manifest dir (`crates/app`), reaching the workspace's + /// `frontend/apps/rd-console/dist`. + #[derive(RustEmbed)] + #[folder = "../../frontend/apps/rd-console/dist"] + struct Assets; + + /// The SPA router over the embedded assets: a path that names an embedded file serves it + /// (with a guessed Content-Type); anything else serves the embedded `index.html` so + /// client-side routes resolve to the SPA shell. + pub fn router() -> Router { + Router::new() + .route("/", get(|| async { serve_path("index.html") })) + .fallback(get(|uri: Uri| async move { + serve_path(uri.path().trim_start_matches('/')) + })) + } + + /// Serve the embedded file at `path`, or fall back to the embedded `index.html` (the SPA + /// shell) when no such file is embedded — so deep links into the console resolve. + fn serve_path(path: &str) -> Response { + if let Some(file) = Assets::get(path) { + return file_response(path, file.data.into_owned()); + } + match Assets::get("index.html") { + Some(index) => file_response("index.html", index.data.into_owned()), + None => ( + StatusCode::NOT_FOUND, + "the RD console was not embedded — the binary was built without the frontend dist", + ) + .into_response(), + } + } + + /// Build a response for an embedded file: its bytes with a Content-Type guessed from the + /// path's extension (defaulting to `application/octet-stream`). + fn file_response(path: &str, bytes: Vec) -> Response { + let mime = mime_guess::from_path(path).first_or_octet_stream(); + ( + [(header::CONTENT_TYPE, mime.as_ref().to_string())], + Body::from(bytes), + ) + .into_response() + } +} + +/// Whether the configured assets directory looks like a built SPA (an `index.html` is +/// present). `main` uses this to print a clear warning when the console has not been built +/// yet, while still serving the API. +pub fn asset_status(assets: &Path) -> AssetStatus { + if !assets.exists() { + AssetStatus::Missing + } else if assets.join("index.html").is_file() { + AssetStatus::Built + } else { + AssetStatus::NoIndex + } +} + +/// The result of inspecting the configured assets directory. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AssetStatus { + /// `index.html` is present — the SPA will be served. + Built, + /// The directory does not exist (no `npm run build` yet). + Missing, + /// The directory exists but has no `index.html`. + NoIndex, +} + +/// A tiny convenience used by the integration test and conceivable health checks: the +/// HTTP status the SPA fallback yields for an unbuilt assets dir (a 404 from `ServeDir`). +pub const UNBUILT_FALLBACK_STATUS: StatusCode = StatusCode::NOT_FOUND; + +/// Whether this binary was built with `--features embed-assets`, i.e. the RD console SPA is +/// baked into the executable and served from memory (the filesystem `assets` dir / the +/// `GRIDFPV_ASSETS` env var are ignored). The Director binary uses this to print an accurate +/// startup banner — when `true`, an absent on-disk dist is **not** a problem. +pub const ASSETS_EMBEDDED: bool = cfg!(feature = "embed-assets"); diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 628875d..8581914 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -8,8 +8,17 @@ //! 3. a **minimal read** renders the lap list as text (#9). //! //! The same pieces back the replay harness (`tests/replay.rs`, #10). +//! +//! As of v0.4 (#13) the binary itself is the **Director server**: `main.rs` opens the +//! event log, builds the protocol [`server::app::router`], serves the built RD console +//! SPA, and binds an address (see the [`director`] module). The walking-skeleton fold +//! below is kept as the `gridfpv demo` subcommand and as the test surface the replay +//! harness exercises. #![forbid(unsafe_code)] +pub mod director; +pub mod source; + use gridfpv_events::{AdapterId, CompetitorRef, Event, GateIndex, Pass, SourceTime}; use gridfpv_projection::{LapList, lap_list}; use gridfpv_storage::{EventLog, Result as StorageResult}; @@ -65,6 +74,7 @@ fn make_pass(adapter: &AdapterId, competitor: &CompetitorRef, at: i64, sequence: sequence: Some(sequence), gate: GateIndex::LAP, signal: None, + heat: None, // the offline demo has no heat construct }) } diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 8426d84..aac1079 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -1,14 +1,150 @@ -//! GridFPV Director binary — the walking-skeleton entry point. +//! GridFPV Director binary — the Director server entry point (#13). //! -//! Runs the whole spine for a synthetic session and prints the derived lap list: -//! synthetic source → append to the SQLite log → read back → project → render. +//! Run with no arguments to start the Director: it opens the event log, builds the +//! protocol read/realtime + control API, serves the built RD console SPA, mints an RD +//! token, and prints the token + URL + asset dir so the RD can log in. Configuration is +//! from the environment (`GRIDFPV_ADDR` / `GRIDFPV_DB` / `GRIDFPV_ASSETS`); see +//! [`gridfpv_app::director::Config`]. +//! +//! `gridfpv demo` runs the original walking-skeleton fold (synthetic session → log → +//! projection → printed lap list), kept from v0.1 for a quick offline smoke. +//! +//! The live-timer pipeline (RH adapter → engine → log append) is a separate later step +//! and is intentionally not wired here — this binary only stands the Director up so a +//! race can be served. #![forbid(unsafe_code)] +use gridfpv_app::director::{ASSETS_EMBEDDED, AssetStatus, Config, asset_status, run_director}; use gridfpv_app::{SyntheticPilot, append_and_project, render_lap_list, synthetic_session}; use gridfpv_storage::SqliteLog; fn main() -> Result<(), Box> { - println!("GridFPV {} — walking skeleton\n", env!("CARGO_PKG_VERSION")); + // Dispatch the offline `demo` subcommand synchronously; the Director server runs on a + // tokio runtime we build by hand (so `demo` needs no async runtime at all). + if std::env::args().nth(1).as_deref() == Some("demo") { + return run_demo(); + } + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + runtime.block_on(serve()) +} + +/// Open the log, wire the Director, print the login details, and serve until shutdown. +/// +/// The actual Director wiring (registry, token, source bridge, presence reconciler, router, +/// bind, serve) lives in the reusable [`gridfpv_app::director::run_director`] — shared with +/// the Tauri native app — so this binary's behavior is identical to when the wiring was +/// inline here. This function only resolves the env config and prints the startup banner via +/// the `on_ready` callback. +async fn serve() -> Result<(), Box> { + let config = Config::from_env()?; + let banner_config = config.clone(); + + run_director( + config.addr, + config.data_dir, + config.assets, + // Print the same startup banner once the listener is bound, before serving. + move |ready| { + print_startup( + &banner_config, + ready.bound, + ready.rd_token.as_deref(), + &ready.source_desc, + ); + }, + // Serve until Ctrl-C. + shutdown_signal(), + ) + .await?; + + println!("gridfpv: shutting down"); + Ok(()) +} + +/// Print the Director's login details on startup: the URL, the RD token (so the RD can +/// authenticate the control path), the log backing, and the asset-dir status. With no token +/// configured (`rd_token` is `None`) the control path is **open** (full-trust by default, +/// #72) — print that instead of a token. +fn print_startup( + config: &Config, + bound: std::net::SocketAddr, + rd_token: Option<&str>, + source_desc: &str, +) { + // For the console URL prefer a loopback-friendly host when bound to all interfaces. + let url_host = if bound.ip().is_unspecified() { + format!("127.0.0.1:{}", bound.port()) + } else { + bound.to_string() + }; + + println!("GridFPV Director {} — serving", env!("CARGO_PKG_VERSION")); + println!(" listening on : http://{bound}"); + println!(" console URL : http://{url_host}/"); + match rd_token { + Some(rd_token) => { + println!(" RD token : {rd_token}"); + println!(" (use as `Authorization: Bearer {rd_token}` on the control path)"); + } + None => { + println!(" RD token : (none — control is OPEN, full-trust)"); + println!( + " (no GRIDFPV_RD_TOKEN configured: the control path requires no credential — \ + safe on loopback / a trusted LAN; set GRIDFPV_RD_TOKEN to gate control)" + ); + } + } + match &config.data_dir { + Some(dir) => println!( + " events : Practice (in-memory) + created events persist under {}", + dir.display() + ), + None => println!( + " events : Practice (in-memory); created events in-memory \ + (non-durable — set GRIDFPV_DATA_DIR to persist)" + ), + } + println!(" lap source : {source_desc}"); + println!( + " (drive a heat to Running via the control path to see synthetic laps; set \ + GRIDFPV_SOURCE / GRIDFPV_SIM_LAPS / GRIDFPV_SIM_LAP_MS to tune)" + ); + // When built with `embed-assets`, the SPA is baked into the binary — the on-disk + // `assets` dir / `GRIDFPV_ASSETS` are ignored, so report the embedded source rather than + // inspecting (and possibly warning about) a filesystem dist that isn't used. + if ASSETS_EMBEDDED { + println!(" RD console : serving SPA from assets embedded in the binary"); + } else { + let assets = config.assets.display(); + match asset_status(&config.assets) { + AssetStatus::Built => println!(" RD console : serving SPA from {assets}"), + AssetStatus::Missing => println!( + " RD console : WARNING — assets dir not found at {assets}; serving the API only \ + (run `cd frontend && npm run build`, or set GRIDFPV_ASSETS)" + ), + AssetStatus::NoIndex => println!( + " RD console : WARNING — {assets} has no index.html; serving the API only \ + (is this the rd-console dist?)" + ), + } + } +} + +/// Resolve when the process is asked to stop (Ctrl-C), so `axum::serve` can shut down +/// gracefully. +async fn shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; +} + +/// The v0.1 walking-skeleton demo: synthetic session → log → projection → printed laps. +fn run_demo() -> Result<(), Box> { + println!( + "GridFPV {} — walking-skeleton demo\n", + env!("CARGO_PKG_VERSION") + ); let events = synthetic_session( "sim", diff --git a/crates/app/src/source.rs b/crates/app/src/source.rs new file mode 100644 index 0000000..381ed77 --- /dev/null +++ b/crates/app/src/source.rs @@ -0,0 +1,3376 @@ +//! The Director's built-in **lap source + control→source bridge** (#13, v0.4). +//! +//! This is the piece that makes "click **Start** → see a race" work with *nothing* +//! installed — no Docker, no hardware, no second process. When the RD drives a heat to +//! `Running` through the control path, the bridge generates synthetic lap-gate passes for +//! that heat's lineup over **real time**, appends them to the one append-only log via +//! [`AppState::append`] (so `/stream` wakes and the console animates), and stops when the +//! heat `Finishes`/`Aborts`/`Scores` (or a newer heat takes the timer). +//! +//! # The seam — [`LapSource`] +//! +//! A lap source is anything that, told a heat went `Running` with a known lineup, *emits +//! lap-gate passes for that heat over time*. The bridge owns the timer/cancellation and +//! the log; a source only decides **what passes to emit and when**: +//! +//! ```ignore +//! trait LapSource { +//! async fn run_heat(&self, heat: HeatRun, sink: &PassSink) -> Result<(), SourceError>; +//! } +//! ``` +//! +//! The bridge calls [`LapSource::run_heat`] inside a cancellable task; the source sleeps +//! between passes and pushes each one through the [`PassSink`] (which appends to the log). +//! Cancellation is cooperative — the bridge drops the future on a `Finished`/`Aborted` +//! transition, so a source must only `.await` between passes (it does, on the sink push +//! and on `sleep`) for the cancel to land promptly. +//! +//! The only concrete source today is [`SimSource`] (pure Rust, deterministic-enough). A +//! **real RotorHazard source** slots in behind the very same trait later: it would connect +//! to an RH server, map the heat's lineup onto RH node seats, and translate RH lap +//! callbacks into [`PassSink::emit`] calls — feature-gated so the default Director stays +//! openssl-free (the sim pulls no network/TLS at all). Nothing in the bridge changes. +//! +//! # How the bridge observes transitions +//! +//! The `/stream` append-notify ([`Notify`](tokio::sync::Notify)) is `pub(crate)` to the +//! server crate, so from the app crate the clean cross-crate option is to **poll the log +//! tail**: the bridge advances an [`Offset`] cursor over +//! [`read_from`](gridfpv_server::app::EventSource::read_from) on a short interval +//! ([`POLL_INTERVAL`]) and reacts to the `HeatScheduled` / `HeatStateChanged` events it +//! sees. On a `Running` transition it looks back for that heat's lineup (its +//! `HeatScheduled`) and spawns the source task; on `Finished`/`Aborted`/`Finalized`/`Restarted` +//! for the running heat — or a *different* heat going `Running` — it cancels the task. At +//! most one heat emits at a time (a single in-flight task), which is plenty for a Director +//! driving one timer. + +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use gridfpv_events::{ + AdapterId, CompetitorRef, Event, GateIndex, HeatId, HeatTransition, Pass, SourceTime, +}; +use gridfpv_projection::{CompetitorKey, registrations}; +use gridfpv_server::app::AppState; +use gridfpv_server::events::EventRegistry; +use gridfpv_server::pilots::PilotDirectory; +use gridfpv_server::scope::EventId; +use gridfpv_server::timers::{TimerId, TimerKind, TimerRegistry}; +use gridfpv_storage::Offset; +use tokio::task::JoinHandle; + +pub mod failover; +pub use failover::active_source; + +#[cfg(feature = "live")] +mod rh_connections; +#[cfg(feature = "live")] +mod rotorhazard; +#[cfg(feature = "live")] +pub use rh_connections::{RhConnections, spawn_rh_reconciler}; + +/// How often the bridge polls the log tail for new heat-loop events. Short enough that a +/// `Start` click feels instant (the first pass lands within a poll), long enough to be a +/// negligible idle cost. A real source would use the RH event callback instead of polling. +pub const POLL_INTERVAL: Duration = Duration::from_millis(150); + +/// The adapter id every sim-generated pass carries, so the lap projection groups them +/// under one synthetic source. A real RH source would use its own adapter id. +pub const SIM_ADAPTER: &str = "sim"; + +/// Default number of laps each sim pilot flies (beyond the holeshot). Overridable via +/// `GRIDFPV_SIM_LAPS`. +pub const DEFAULT_SIM_LAPS: u32 = 5; + +/// Default real-time pace of a sim lap, in milliseconds. Overridable via +/// `GRIDFPV_SIM_LAP_MS`. The console animates at this cadence; passes are spaced this far +/// apart in real time (with mild per-pilot variation). +pub const DEFAULT_SIM_LAP_MS: u64 = 2500; + +// --- the seam ------------------------------------------------------------------------- + +/// One heat handed to a [`LapSource`]: which heat, and its lineup (in seeding order). +#[derive(Debug, Clone)] +pub struct HeatRun { + /// The heat that just went `Running`. + pub heat: HeatId, + /// The competitors to emit passes for (the heat's `HeatScheduled` lineup). + pub lineup: Vec, +} + +/// The shared **active-source gate** (issue #112): the single selected timer whose passes are +/// currently fed into the log, the rest being hot-standby alternates whose passes are dropped. +/// +/// The bridge re-evaluates the active source every poll (so a primary drop fails over to an +/// alternate live, mid-heat) and stores it here; each source's [`PassSink`] is bound to its own +/// owning timer id and only appends while it *is* the active source. Cloning shares the one cell +/// (`Arc>`) across every source feeding one heat. +#[derive(Clone, Default)] +pub struct ActiveSourceGate { + inner: Arc>>, +} + +impl ActiveSourceGate { + /// A gate with no active source yet (nothing feeds until [`set`](Self::set)). + pub fn new() -> Self { + Self::default() + } + + /// Set the currently-active source (the bridge calls this each poll). `None` ⇒ no selected + /// timer is healthy, so nothing feeds. + pub fn set(&self, active: Option) { + *self.inner.write().expect("active-source gate poisoned") = active; + } + + /// Whether `timer` is the active source right now — the sink's append gate. + pub fn is_active(&self, timer: &TimerId) -> bool { + self.inner + .read() + .expect("active-source gate poisoned") + .as_ref() + == Some(timer) + } +} + +/// The append surface a [`LapSource`] pushes passes through. Wraps the shared +/// [`AppState`] so every emitted pass lands in the one log and wakes `/stream`. +/// +/// When bound to an [`ActiveSourceGate`] and an owning [`TimerId`] (issue #112), the sink only +/// appends while its timer is the **active source** — a hot-standby alternate's passes are dropped +/// so the same crossing is never double-counted. An unbound sink (`gate`/`timer` `None`) always +/// feeds, preserving the pre-#112 single-timer behaviour exactly. +#[derive(Clone)] +pub struct PassSink { + state: AppState, + adapter: AdapterId, + /// The active-source gate this sink is bound to (issue #112), or `None` for an always-feeding + /// sink (single-timer events / non-failover callers). + gate: Option, + /// The timer this sink feeds for; appends pass the gate only while it is the active source. + timer: Option, + /// The **open-practice** heat this sink feeds, if any (open-practice format, Slice 1). When set, + /// the sink routes passes into the event's in-memory per-channel accumulator (NOT the log) and + /// wakes `/stream` to push the fresh per-channel live state — so an open-practice session's + /// passes are *never* appended to the durable log (only its `HeatScheduled` + start/stop are). + open_practice: Option, + /// The heat this sink feeds — **stamped onto every appended pass** (`Pass::heat`), so pass + /// attribution is by tag, not log position (a heat-span event landing mid-race can no longer + /// steal the running heat's laps). The bridge sets it when it builds a Running heat's sinks; + /// `None` (a bare test/demo sink) appends untagged, positional-legacy passes. + heat: Option, +} + +impl PassSink { + /// A sink over `state` tagging passes with `adapter`, **always feeding** (no active-source + /// gate). Used where a single source owns the heat or by callers that don't fail over. + pub fn new(state: AppState, adapter: AdapterId) -> Self { + Self { + state, + adapter, + gate: None, + timer: None, + open_practice: None, + heat: None, + } + } + + /// A sink bound to an [`ActiveSourceGate`] and its owning `timer` (issue #112): it appends only + /// while `timer` is the active source, so an alternate's passes are dropped (hot standby). + pub fn gated( + state: AppState, + adapter: AdapterId, + gate: ActiveSourceGate, + timer: TimerId, + ) -> Self { + Self { + state, + adapter, + gate: Some(gate), + timer: Some(timer), + open_practice: None, + heat: None, + } + } + + /// Mark this sink as feeding the **open-practice** `heat` (open-practice format, Slice 1): + /// passes are routed into the event's in-memory per-channel accumulator and `/stream` is woken, + /// rather than appended to the log. Builder style — applied to a gated/plain sink for an + /// open-practice heat so its laps are tracked live but never logged. + pub fn for_open_practice(mut self, heat: HeatId) -> Self { + self.open_practice = Some(heat); + self + } + + /// Bind this sink to the heat it feeds: every appended pass is stamped `Pass::heat` so the + /// folds attribute it by TAG (robust against heat-span events landing mid-race), never by + /// log position alone. Builder style, applied when the bridge builds a Running heat's sinks. + pub fn for_heat(mut self, heat: HeatId) -> Self { + self.heat = Some(heat); + self + } + + /// Whether this sink may append right now: an unbound sink always may; a gated sink may only + /// while its owning timer is the active source (issue #112). + fn feeds(&self) -> bool { + match (&self.gate, &self.timer) { + (Some(gate), Some(timer)) => gate.is_active(timer), + _ => true, + } + } + + /// Emit one lap-gate pass for `competitor` at race-relative time `at` (ms since the + /// race start, like RH), with a per-pilot monotonic `sequence`. Appends through + /// [`AppState::append`] so the live state updates and `/stream` wakes. + pub fn emit( + &self, + competitor: &CompetitorRef, + at: SourceTime, + sequence: u64, + ) -> Result<(), SourceError> { + // Issue #112: a hot-standby alternate's passes are dropped — only the active source feeds. + // The source still runs (stays armed/draining) so a failover to it lands instantly. + if !self.feeds() { + return Ok(()); + } + let pass = Pass { + adapter: self.adapter.clone(), + competitor: competitor.clone(), + at, + sequence: Some(sequence), + gate: GateIndex::LAP, + signal: None, + heat: self.heat.clone(), + }; + // Open practice (open-practice format, Slice 1): route the pass into the in-memory + // per-channel accumulator and wake `/stream` — it is **never** appended to the log. + if self.open_practice.is_some() { + if self.state.open_practice().record(pass) { + self.state.wake_streams(); + } + return Ok(()); + } + self.state + .append(Event::Pass(pass), None) + .map_err(|e| SourceError(format!("{e:?}")))?; + Ok(()) + } + + /// Append an already-built canonical [`Event`] through this sink's [`AppState`], stamping + /// passes with the sink's `adapter` id. Used by the live RotorHazard source to feed the + /// adapter's translated passes (which carry their own real signal context, source-clock + /// timestamps and per-node sequence) straight into the event log — rather than re-synthesizing + /// them through [`emit`](Self::emit). Returns the resulting [`Offset`] on success. + #[cfg(feature = "live")] + pub(crate) fn append_event(&self, event: Event) -> Result<(), SourceError> { + // Issue #112: drop an alternate RH connection's passes while it is not the active source. + // The connection stays live (hot standby) — only its appends are gated here. + if !self.feeds() { + return Ok(()); + } + // Open practice (open-practice format, Slice 1): a lap-gate pass from a live RH source is + // routed into the in-memory accumulator (not logged); any non-pass event still appends. + if self.open_practice.is_some() { + if let Event::Pass(pass) = event { + if pass.gate.is_lap_gate() && self.state.open_practice().record(pass) { + self.state.wake_streams(); + } + return Ok(()); + } + } + // Stamp the sink's heat onto the pass (tag attribution — see `for_heat`): the adapter + // built the pass without one; the sink is the component that knows which heat it feeds. + let event = match event { + Event::Pass(mut pass) if self.heat.is_some() => { + pass.heat = self.heat.clone(); + Event::Pass(pass) + } + other => other, + }; + self.state + .append(event, None) + .map_err(|e| SourceError(format!("{e:?}")))?; + Ok(()) + } + + /// This sink's adapter id (the configured RH timer's adapter), for re-stamping translated + /// passes onto a single source in the lap projection. + #[cfg(feature = "live")] + pub(crate) fn adapter(&self) -> &AdapterId { + &self.adapter + } +} + +/// A lap source: emits lap-gate passes for one running heat over time. +/// +/// Implementors own *what* passes to emit and *when* (sleeping between them); the bridge +/// owns the log handle (via the [`PassSink`]) and the task lifecycle (spawn on `Running`, +/// cancel on `Finished`/`Aborted`). The future is dropped to cancel, so implementors must +/// only hold state across `.await` points that are safe to abandon mid-flight (a partly +/// emitted heat just stops — the log keeps whatever passes already landed). +pub trait LapSource: Send + Sync + 'static { + /// Drive `run` to completion, pushing each pass through `sink`. Returns when the heat's + /// synthetic passes are exhausted (or on error); may be cancelled early by the bridge + /// dropping the returned future. + fn run_heat( + &self, + run: HeatRun, + sink: PassSink, + ) -> std::pin::Pin> + Send>>; +} + +/// An error from a lap source — today only "the log append failed" (a poisoned lock or a +/// storage error), surfaced so the bridge can log it and stop the heat. +#[derive(Debug, Clone)] +pub struct SourceError(pub String); + +impl std::fmt::Display for SourceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "lap source error: {}", self.0) + } +} + +impl std::error::Error for SourceError {} + +// --- the sim source ------------------------------------------------------------------- + +/// The built-in synthetic lap source: a holeshot plus `laps` laps per pilot at `lap` +/// real-time pace, with mild deterministic per-pilot variation so the running order is not +/// a flat tie. +/// +/// Per pilot it emits `laps + 1` lap-gate passes: the **holeshot** (the first pass, which +/// starts the pilot's clock at race-relative `0` plus a small stagger) then one pass per +/// lap. `Pass.at` is the race-relative [`SourceTime`] in microseconds — milliseconds since +/// the race start, mirroring how RotorHazard reports lap times on a per-race clock. +/// +/// Pacing is **real time**: between passes the source sleeps the (varied) lap duration so +/// the console animates laps ticking in. Tests inject a tiny `lap` (e.g. 1ms) so the whole +/// heat runs in well under a second with no special clock plumbing. +#[derive(Debug, Clone)] +pub struct SimSource { + /// Laps each pilot flies beyond the holeshot. + pub laps: u32, + /// The nominal real-time pace of one lap. + pub lap: Duration, +} + +impl SimSource { + /// A sim source with explicit knobs. + pub fn new(laps: u32, lap: Duration) -> Self { + Self { laps, lap } + } + + /// Build from the environment knobs (`GRIDFPV_SIM_LAPS`, `GRIDFPV_SIM_LAP_MS`), + /// falling back to [`DEFAULT_SIM_LAPS`] / [`DEFAULT_SIM_LAP_MS`] when unset or + /// unparseable. + pub fn from_env() -> Self { + let laps = parse_env_u32("GRIDFPV_SIM_LAPS").unwrap_or(DEFAULT_SIM_LAPS); + let lap_ms = parse_env_u64("GRIDFPV_SIM_LAP_MS").unwrap_or(DEFAULT_SIM_LAP_MS); + Self::new(laps, Duration::from_millis(lap_ms)) + } + + /// The per-pilot lap pace: the nominal pace scaled by a small deterministic factor + /// derived from the pilot's index, so pilots don't all cross in lockstep and the + /// running order is decided. Index 0 is the nominal pace; later pilots are a few + /// percent slower, spreading the field. + fn pilot_lap(&self, pilot_index: usize) -> Duration { + // +4% per seed position, capped so the spread stays modest even for big fields. + let pct = 4u32.saturating_mul(pilot_index.min(8) as u32); + let scaled = self.lap.as_micros() as u64 * (100 + pct as u64) / 100; + Duration::from_micros(scaled) + } +} + +impl LapSource for SimSource { + fn run_heat( + &self, + run: HeatRun, + sink: PassSink, + ) -> std::pin::Pin> + Send>> { + let this = self.clone(); + Box::pin(async move { + // Per-pilot race-relative clock (µs) and monotonic sequence. Each pilot is + // driven independently so their passes interleave by real wall-clock time — + // the bridge appends whichever lands first. + // + // A simple serialized timer: walk lap-by-lap across all pilots, sleeping the + // pace before each pilot's next pass. The holeshot (lap 0) opens each pilot's + // clock with a small stagger so seeds don't tie at exactly 0. + let mut clock_micros: Vec = vec![0; run.lineup.len()]; + let mut sequence: Vec = vec![0; run.lineup.len()]; + + // Holeshots: stagger seeds by ~one tenth of a lap so the start order is the + // seeding order (and times are distinct). + for (i, competitor) in run.lineup.iter().enumerate() { + let stagger = this.pilot_lap(i).as_micros() as i64 / 10 * i as i64; + clock_micros[i] = stagger; + sink.emit(competitor, SourceTime::from_micros(stagger), sequence[i])?; + } + + // Then `laps` laps: each lap, every pilot crosses once, paced in real time. + for _lap in 0..this.laps { + for (i, competitor) in run.lineup.iter().enumerate() { + let lap = this.pilot_lap(i); + tokio::time::sleep(lap).await; + clock_micros[i] += lap.as_micros() as i64; + sequence[i] += 1; + sink.emit( + competitor, + SourceTime::from_micros(clock_micros[i]), + sequence[i], + )?; + } + } + Ok(()) + }) + } +} + +// --- the bridge ----------------------------------------------------------------------- + +/// The configured lap source for the Director, selected by `GRIDFPV_SOURCE`. +/// +/// `sim` (the default) is the only source implemented here. `rh:` is **reserved** for +/// the later feature-gated RotorHazard source; selecting it today logs an "unsupported, +/// using sim" line and falls back to the sim, so the env var is forward-compatible. +pub enum SourceConfig { + /// The built-in synthetic source. + Sim(SimSource), +} + +impl SourceConfig { + /// Resolve the source from `GRIDFPV_SOURCE` (+ the sim knobs). Returns the config and a + /// human-readable description for the startup banner. + pub fn from_env() -> Self { + let raw = std::env::var("GRIDFPV_SOURCE").unwrap_or_default(); + let raw = raw.trim(); + if raw.is_empty() || raw.eq_ignore_ascii_case("sim") { + return SourceConfig::Sim(SimSource::from_env()); + } + // `rh:` (and anything else) is not wired in the default Director yet. + eprintln!( + "gridfpv: GRIDFPV_SOURCE={raw:?} is not supported in this build (the RotorHazard \ + source is feature-gated and not compiled in) — using the built-in sim source" + ); + SourceConfig::Sim(SimSource::from_env()) + } + + /// A one-line description of the active source, for the startup banner. + pub fn describe(&self) -> String { + match self { + SourceConfig::Sim(sim) => format!( + "sim (holeshot + {} laps @ ~{}ms/lap real-time, with per-pilot variation)", + sim.laps, + sim.lap.as_millis() + ), + } + } +} + +/// How often the registry-aware spawner polls for newly-created events to attach a bridge to. +pub const REGISTRY_POLL_INTERVAL: Duration = Duration::from_millis(500); + +/// Spawn a **per-event** control→source bridge across the whole [`EventRegistry`] (issue #72/#73). +/// +/// With events now first-class containers, each event has its **own** log, so the source is +/// per-event: the Director runs one [`run_bridge`] per event, feeding passes into THAT event's +/// log when a heat goes `Running` *there*. Which source(s) run is now the **event's selected +/// timers** (issue #73), not a single global env source: each bridge reads its event's +/// [`EventMeta::timers`](gridfpv_server::events::EventMeta::timers) selection live (resolving each +/// id through the app-level [`TimerRegistry`]) when a heat starts. A selected **Mock** timer runs +/// the synthetic emission with *that timer's* `laps`/`lap_ms`; a selected **RotorHazard** timer is +/// a no-op stub (2b / #65 connects it). The built-in Mock's config comes from the env +/// defaults seeded into the timer registry. +/// +/// This spawner seeds a bridge for every event present at startup (Practice + any already-loaded +/// persistent events) and polls the registry on [`REGISTRY_POLL_INTERVAL`] to attach a bridge to +/// any event *created at runtime* (`POST /events`). So every event can run independently and +/// concurrently. The `SourceConfig` argument is retained for the startup banner only. +/// +/// Returns the spawner's [`JoinHandle`]; the per-event bridge tasks it spawns run for the +/// process lifetime (each ends when its event's log handle is dropped at shutdown). +pub fn spawn_registry_bridge( + registry: EventRegistry, + _source: SourceConfig, + adapter: AdapterId, +) -> JoinHandle<()> { + // Under the `live` feature, also spawn the persistent RotorHazard connection reconciler (#105): + // the active event's selected RH timers connect on selection and stay connected (status + // monitored continuously), and a running heat arms onto that *already-live* connection rather + // than dialing per heat. The per-event bridge shares the resulting connection set so it can + // arm/disarm heats on the live connections. A non-`live` build keeps RH a no-op stub. + #[cfg(feature = "live")] + let connections = { + let (connections, _reconciler) = spawn_rh_reconciler(registry.clone()); + connections + }; + + let timers = registry.timers(); + tokio::spawn(async move { + // The set of events that already have a bridge, so each event is attached exactly once. + let mut attached: HashSet = HashSet::new(); + let mut ticker = tokio::time::interval(REGISTRY_POLL_INTERVAL); + loop { + // Prune ids that left the registry: their per-event tasks exit on their own (they + // watch for the event's disappearance), and dropping them here keeps this set from + // growing forever across create/delete cycles. + let live: HashSet = registry.list().into_iter().map(|m| m.id).collect(); + attached.retain(|id| live.contains(id)); + for meta in registry.list() { + if attached.contains(&meta.id) { + continue; + } + // Resolve the event's own AppState/log and spawn its selection-aware bridge. + if let Some(state) = registry.resolve(&meta.id) { + let registry = registry.clone(); + let timers = timers.clone(); + let adapter = adapter.clone(); + let event_id = meta.id.clone(); + #[cfg(feature = "live")] + let connections = connections.clone(); + tokio::spawn(async move { + run_bridge( + state, + registry, + timers, + event_id, + adapter, + #[cfg(feature = "live")] + connections, + ) + .await; + }); + attached.insert(meta.id); + } + } + ticker.tick().await; + } + }) +} + +/// The bridge loop: poll the log tail, drive the event's **selected timers** on `Running`, cancel +/// on `Finished`/`Aborted`/`Finalized`/`Restarted` (or a newer heat going `Running`). +/// +/// On a `Running` transition the bridge reads the event's current +/// [`EventMeta::timers`](gridfpv_server::events::EventMeta::timers) selection from `registry`, +/// resolves each id through `timers`, and builds the [`LapSource`] for it (a Sim timer's +/// `laps`/`lap_ms`; a RotorHazard timer is skipped as a no-op stub). Exposed (crate-internal) so +/// the test harness can run it directly against an in-memory [`AppState`]. +pub(crate) async fn run_bridge( + state: AppState, + registry: EventRegistry, + timers: TimerRegistry, + event_id: EventId, + adapter: AdapterId, + #[cfg(feature = "live")] connections: RhConnections, +) { + // The in-flight heat task, if a heat is currently emitting. At most one at a time. + let mut active: Option = None; + // The runtime-clock drivers (heat-lifecycle Slice 2), keyed per heat (see `HeatClock`). + let mut clock = HeatClock::default(); + // START FROM THE CURRENT STATE, THEN FOLLOW THE TAIL — never replay history. A persistent + // event's log holds every past heat's transitions; replaying them re-fired start drivers + // (spurious `HeatStarting` facts, stale `Running` races) and re-spawned sim sources whose + // synchronous holeshots corrupted an already-scored heat's window on every Director + // restart. Instead: one read snapshots the log — the cursor starts at its tail, and any + // heat CURRENTLY in a runtime-driven state (Armed / Running / Unofficial) is handed to the + // normal transition handler once, as if its state had just been observed. A mid-race + // Director restart therefore re-arms the heat's sources and completion clock (fresh clocks + // over current state — the old process's timers died with it) and the race still auto-ends; + // finished history stays untouched. + let mut cursor: Offset = match read_tail(&state, 0) { + Ok(batch) => { + let tail = batch.last().map(|(offset, _)| offset + 1).unwrap_or(0); + let events: Vec = batch.into_iter().map(|(_, e)| e).collect(); + for (heat, synthetic) in in_flight_heats(&events) { + handle_transition( + &state, + ®istry, + &timers, + &event_id, + &adapter, + &mut active, + &mut clock, + #[cfg(feature = "live")] + &connections, + heat, + synthetic, + ); + } + tail + } + Err(_) => return, + }; + let mut ticker = tokio::time::interval(POLL_INTERVAL); + + loop { + ticker.tick().await; + + // A DELETED event ends its bridge: the AppState Arc outlives the registry entry, so + // without this check the orphaned task would poll a dead log forever. + if registry.resolve(&event_id).is_none() { + return; + } + + // Reap a finished/cancelled source task so a heat that ran to the end clears the + // slot (without it, a re-Start of the same heat would be ignored). A heat with an armed + // RotorHazard connection is NOT reaped on the Mock tasks finishing — the live connection + // keeps draining real passes until the heat leaves `Running` (it is disarmed there). + if let Some(running) = &active { + let has_armed_rh = { + #[cfg(feature = "live")] + { + !running.armed_rh.is_empty() + } + #[cfg(not(feature = "live"))] + { + false + } + }; + if running.is_finished() && !has_armed_rh { + active = None; + } + } + + // Issue #112 — live failover: while a heat is running, re-evaluate the **active source** + // each poll from the event's current selection + the live timer statuses, and update the + // gate. A primary drop (its RH connection leaves `Connected`) hands the feed to the first + // healthy alternate; a primary recovery takes it back — all without touching the running + // sources (they keep emitting; the gate decides whose passes land). + if let Some(running) = &active { + if let Some(meta) = registry.meta_of(&event_id) { + running.gate.set(active_source(&meta, &timers)); + } + } + + let new_events = match read_tail(&state, cursor) { + Ok(batch) => batch, + // A transient read error (an I/O blip on the SQLite log) must NOT end the event's + // runtime — that silently killed every start/completion/auto-official clock until + // the next Director restart, sticking heats in Armed forever. Warn and retry on the + // next tick; a genuinely dropped log at shutdown just keeps idling until the process + // exits (the bridge holds a strong state handle either way). + Err(e) => { + eprintln!("gridfpv: bridge could not read the event log (will retry): {e:?}"); + continue; + } + }; + if new_events.is_empty() { + continue; + } + + for (offset, event) in new_events { + cursor = offset + 1; + // Only heat-loop transitions drive the bridge. Other events (HeatScheduled, + // passes, registrations, …) need no action — the lineup is looked up from the + // log when the heat goes Running. + if let Event::HeatStateChanged { heat, transition } = event { + handle_transition( + &state, + ®istry, + &timers, + &event_id, + &adapter, + &mut active, + &mut clock, + #[cfg(feature = "live")] + &connections, + heat, + transition, + ); + } + } + } +} + +// --- the sim auto-presence reconciler (race redesign Slice 1a) ------------------------- + +/// Spawn the **sim auto-presence reconciler** across the whole [`EventRegistry`] (race redesign +/// Slice 1a). +/// +/// "Presence = roster membership": a pilot on an [`EventMeta::roster`] *is* present at the event. +/// When the sim (Velocidrone) adapter reports a player via [`Event::CompetitorSeen`], the RD would +/// normally have to add that pilot to the roster and bind the timing-source competitor to a GridFPV +/// pilot by hand. This reconciler does it automatically for the sim: per active event it tails the +/// log for `CompetitorSeen`, and for each seen competitor **not yet bound** whose name matches a +/// **directory pilot's callsign**, it (a) adds that pilot to the event's roster (= present) if +/// absent, and (b) appends an [`Event::CompetitorRegistered`] binding (the same binding the RD's +/// [`Command::Register`](gridfpv_server::control::Command::Register) produces, folded by +/// [`registrations`]). Unmatched / unrostered-but-no-matching-pilot seen players are a no-op (the RD +/// handles them in Slice 1b). +/// +/// The reconcile is **idempotent**: roster add is set-membership (no duplicate) and a binding is +/// only appended for a competitor with no existing registration, so re-seeing a player does nothing. +/// +/// Mirrors [`spawn_registry_bridge`]: it seeds a reconciler task per event present at startup and +/// polls the registry on [`REGISTRY_POLL_INTERVAL`] to attach one to any event created at runtime. +/// Returns the spawner's [`JoinHandle`]; the per-event tasks run for the process lifetime. +pub fn spawn_presence_reconciler(registry: EventRegistry) -> JoinHandle<()> { + tokio::spawn(async move { + let mut attached: HashSet = HashSet::new(); + let mut ticker = tokio::time::interval(REGISTRY_POLL_INTERVAL); + loop { + let live: HashSet = registry.list().into_iter().map(|m| m.id).collect(); + attached.retain(|id| live.contains(id)); + for meta in registry.list() { + if attached.contains(&meta.id) { + continue; + } + if let Some(state) = registry.resolve(&meta.id) { + let registry = registry.clone(); + let event_id = meta.id.clone(); + tokio::spawn(async move { + run_presence_reconciler(state, registry, event_id).await; + }); + attached.insert(meta.id); + } + } + ticker.tick().await; + } + }) +} + +/// The per-event auto-presence loop (race redesign Slice 1a): poll the log tail for +/// [`Event::CompetitorSeen`] and reconcile each one into presence + a binding. +/// +/// Polls the event's log on [`POLL_INTERVAL`] from a cursor. On each batch it folds the *whole* +/// log's [`registrations`] (so "already bound" reflects every binding, RD- or auto-made) and, for +/// each newly-seen competitor in the batch, calls [`reconcile_seen`]. Exposed (crate-internal) so +/// the test harness can run it directly against an in-memory [`AppState`]. +pub(crate) async fn run_presence_reconciler( + state: AppState, + registry: EventRegistry, + event_id: EventId, +) { + let pilots = registry.pilots(); + let mut cursor: Offset = 0; + let mut ticker = tokio::time::interval(POLL_INTERVAL); + loop { + ticker.tick().await; + // A DELETED event ends its reconciler (the log Arc alone would keep it alive forever). + if registry.resolve(&event_id).is_none() { + return; + } + let new_events = match read_tail(&state, cursor) { + Ok(batch) => batch, + // A poisoned lock (or a dropped log at shutdown) ends the reconciler cleanly. + Err(_) => return, + }; + if new_events.is_empty() { + continue; + } + // Fold the current bindings over the whole log so "already bound" is authoritative + // (an RD bind or a prior auto-bind both count). Cheap: one read; the log is per-event. + let bindings = match read_all(&state) { + Ok(events) => registrations(events.iter()), + Err(_) => continue, + }; + // Dedup WITHIN the batch: `bindings` was folded once before the loop, so two + // CompetitorSeen for the same key in one poll batch both read "unbound" and appended + // twice (benign — same key/value, last-wins fold — but noise in the log). + let mut seen_this_batch: HashSet<(AdapterId, CompetitorRef)> = HashSet::new(); + for (offset, event) in new_events { + cursor = offset + 1; + if let Event::CompetitorSeen { + adapter, + competitor, + } = event + { + if !seen_this_batch.insert((adapter.clone(), competitor.clone())) { + continue; + } + reconcile_seen( + &state, ®istry, &pilots, &event_id, &bindings, adapter, competitor, + ); + } + } + } +} + +/// Reconcile one seen competitor into presence + a binding (race redesign Slice 1a). +/// +/// No-op when the competitor is **already bound** (its `(adapter, competitor)` is in `bindings`), +/// or when **no directory pilot's callsign matches** the competitor name. Otherwise: add the +/// matched pilot to the event's [`roster`](gridfpv_server::events::EventMeta::roster) (idempotent — +/// set membership = presence) and append an [`Event::CompetitorRegistered`] binding so the lap +/// projection attributes the sim player's laps to that pilot. Best-effort: a roster/append failure +/// is logged-shaped (eprintln) and skipped rather than crashing the reconciler. +fn reconcile_seen( + state: &AppState, + registry: &EventRegistry, + pilots: &PilotDirectory, + event_id: &EventId, + bindings: &std::collections::BTreeMap, + adapter: AdapterId, + competitor: CompetitorRef, +) { + let key = CompetitorKey { + adapter: adapter.clone(), + competitor: competitor.clone(), + }; + // Already bound (by the RD or a prior auto-bind) → nothing to do (idempotent). + if bindings.contains_key(&key) { + return; + } + // Match the seen competitor name against a directory pilot's callsign. Unmatched → no-op. + let Some(pilot_id) = match_callsign(pilots, &competitor) else { + return; + }; + // (a) Presence: add the matched pilot to the event's roster (idempotent set-membership). + if let Err(e) = registry.add_to_roster(event_id, pilot_id.clone()) { + eprintln!("gridfpv: auto-presence could not add pilot to roster: {e}"); + return; + } + // (b) Binding: append the CompetitorRegistered the RD's `Register` command would (#60), so + // the sim player's laps attribute to the matched pilot. + if let Err(e) = state.append( + Event::CompetitorRegistered { + adapter, + competitor, + pilot: pilot_id, + }, + None, + ) { + eprintln!("gridfpv: auto-presence could not append binding: {e:?}"); + } +} + +/// Find the directory pilot whose **callsign matches** the seen competitor name (race redesign +/// Slice 1a), or `None`. The match is **case-insensitive and trimmed** so a sim player name and a +/// stored callsign that differ only in surrounding whitespace or case still bind. The directory is +/// listed in id order, so the first matching pilot wins deterministically. +fn match_callsign( + pilots: &PilotDirectory, + competitor: &CompetitorRef, +) -> Option { + let name = competitor.0.trim(); + pilots + .list() + .into_iter() + .find(|p| p.callsign.trim().eq_ignore_ascii_case(name)) + .map(|p| p.id) +} + +/// Read the whole event log, returning its [`Event`]s in append order. A thin wrapper over the +/// shared log handle used by the presence reconciler to fold the current registration bindings. +fn read_all(state: &AppState) -> Result, SourceError> { + let stored = state + .log() + .lock() + .map_err(|_| SourceError("the event log lock was poisoned".into()))? + .read_all() + .map_err(|e| SourceError(e.to_string()))?; + Ok(stored.into_iter().map(|s| s.event).collect()) +} + +/// Resolve the event's selected **Mock** timers into the [`SimSource`]s to run for this heat +/// (issues #73, #105). +/// +/// Reads the event's current `timers` selection from `registry`, looks each id up in the app-level +/// `timers` registry, and maps each **Mock** timer to a [`SimSource`] with that timer's +/// `laps`/`lap_ms`. A selected **RotorHazard** timer is *not* a per-heat source here — under the +/// `live` feature it is driven through its **persistent connection** (#105): the heat is armed onto +/// the already-live connection (see [`selected_rh_timers`] + [`handle_transition`]) rather than a +/// new socket dialed each heat. In a **non-`live`** build a RotorHazard timer is a no-op stub. An id +/// that no longer resolves (a since-deleted timer) is skipped. Returns the synthetic sources to +/// drive concurrently for the running heat — empty when the event selects no usable Mock timer. +fn selected_sources( + registry: &EventRegistry, + timers: &TimerRegistry, + event_id: &EventId, +) -> Vec<(TimerId, Arc)> { + let Some(selection) = registry.timers_of(event_id) else { + return Vec::new(); + }; + let mut sources: Vec<(TimerId, Arc)> = Vec::new(); + for id in selection { + let Some(timer) = timers.get(&id) else { + continue; + }; + match timer.kind { + TimerKind::Mock { laps, lap_ms } => { + sources.push(( + id, + Arc::new(SimSource::new(laps, Duration::from_millis(lap_ms))), + )); + } + // RotorHazard is driven through its persistent connection (#105), not a per-heat source. + TimerKind::Rotorhazard { .. } => {} + } + } + sources +} + +/// The event's selected **RotorHazard** timer ids (issue #105) — the timers a running heat arms onto +/// their already-live persistent connections (race driving decoupled from connecting). An id that no +/// longer resolves, or is not a RotorHazard timer, is skipped. +#[cfg(feature = "live")] +fn selected_rh_timers( + registry: &EventRegistry, + timers: &TimerRegistry, + event_id: &EventId, +) -> Vec { + let Some(selection) = registry.timers_of(event_id) else { + return Vec::new(); + }; + selection + .into_iter() + .filter(|id| { + matches!( + timers.get(id).map(|t| t.kind), + Some(TimerKind::Rotorhazard { .. }) + ) + }) + .collect() +} + +/// React to a heat-loop transition: start emitting from the event's selected timers on `Running`, +/// stop on a terminal / off-ramp transition for the heat that is currently emitting. +#[allow(clippy::too_many_arguments)] +fn handle_transition( + state: &AppState, + registry: &EventRegistry, + timers: &TimerRegistry, + event_id: &EventId, + adapter: &AdapterId, + active: &mut Option, + clock: &mut HeatClock, + #[cfg(feature = "live")] connections: &RhConnections, + heat: HeatId, + transition: HeatTransition, +) { + // Heat-lifecycle Slice 2 — the runtime clock. Any transition for THIS heat cancels its in-flight + // clock drivers first (a stale countdown/completion must never append after the heat moved on); + // the per-state arms below then (re)spawn the driver the new state wants. A transition for a + // *different* heat leaves this heat's drivers alone. + clock.cancel_for(&heat); + match transition { + HeatTransition::Running => { + // A different heat taking the timer cancels the previous one (only one heat + // emits at a time). Re-running the *same* heat also restarts cleanly. A superseded + // open-practice heat's accumulator is cleared below by `begin`/the clear-on-stop. + if let Some(running) = active.take() { + running.stop( + #[cfg(feature = "live")] + connections, + #[cfg(feature = "live")] + event_id, + ); + } + let Some(lineup) = lineup_of(state, &heat) else { + // No HeatScheduled for this heat (or the log read failed): nothing to emit. + return; + }; + if lineup.is_empty() { + return; + } + // Open practice (open-practice format, Slice 1): an open-practice heat's passes are + // tracked **in memory, per channel — never logged**. Begin the accumulator over the + // heat's channel lineup (this also clears any prior open-practice state, e.g. a + // superseded heat) and mark each source's sink so its passes route there + wake + // `/stream`. A non-open-practice heat leaves the accumulator untouched. + let open_practice = is_open_practice_heat(state, registry, event_id, &heat); + if open_practice { + state.open_practice().begin(heat.clone(), lineup.clone()); + // Push the cleared/initial live state so a subscriber sees the fresh empty heat. + state.wake_streams(); + } + // Issue #112: the **active-source gate** — only the active source's passes feed the log + // (the primary while healthy, else the first healthy alternate). All selected timers + // still run (hot standby); the gate drops a non-active source's passes. It is seeded + // immediately so the very first pass is gated correctly, then re-evaluated each poll in + // `run_bridge` so a primary drop fails over mid-heat. + let gate = ActiveSourceGate::new(); + if let Some(meta) = registry.meta_of(event_id) { + gate.set(active_source(&meta, timers)); + } + // Resolve the event's selected Mock timers to the synthetic source(s) to run — each + // bound to a gated sink for its own timer id, so only the active one feeds. + let sources = selected_sources(registry, timers, event_id); + let mut handles = Vec::with_capacity(sources.len()); + for (timer_id, source) in sources { + let mut sink = + PassSink::gated(state.clone(), adapter.clone(), gate.clone(), timer_id) + .for_heat(heat.clone()); + if open_practice { + sink = sink.for_open_practice(heat.clone()); + } + let run = HeatRun { + heat: heat.clone(), + lineup: lineup.clone(), + }; + handles.push(tokio::spawn(async move { + if let Err(e) = source.run_heat(run, sink).await { + eprintln!("gridfpv: timer source stopped: {e}"); + } + })); + } + // Arm the heat onto each selected RotorHazard timer's already-live persistent connection + // (#105): the connection stages the race + drains its passes into THIS event's log. This + // reuses the connection opened on selection rather than dialing per heat. Each arming's + // sink is gated on its own timer id (issue #112) so an alternate RH connection stays live + // but its passes are dropped while it is not the active source. + #[cfg(feature = "live")] + let armed_rh = { + let mut armed = Vec::new(); + for timer_id in selected_rh_timers(registry, timers, event_id) { + let mut sink = PassSink::gated( + state.clone(), + adapter.clone(), + gate.clone(), + timer_id.clone(), + ) + .for_heat(heat.clone()); + if open_practice { + sink = sink.for_open_practice(heat.clone()); + } + if connections.arm_heat(event_id, &timer_id, lineup.clone(), sink) { + armed.push(timer_id); + } + } + armed + }; + #[cfg(feature = "live")] + let nothing_armed = armed_rh.is_empty(); + #[cfg(not(feature = "live"))] + let nothing_armed = true; + // Spawn the completion clock (heat-lifecycle Slice 2): it watches the running passes and + // auto-appends `Finished` once the win condition + grace are met. Independent of whether + // any source emits — a real RH heat with no Mock source still needs auto-completion. + HeatClock::install( + &mut clock.completion, + heat.clone(), + spawn_completion_driver(state, registry, event_id, heat.clone()), + ); + + if handles.is_empty() && nothing_armed { + return; + } + *active = Some(ActiveHeat { + heat, + handles, + gate, + #[cfg(feature = "live")] + armed_rh, + }); + } + // Any transition that takes the heat off `Running` stops its emission. The bridge + // only emits while `Running`, mirroring `consumes_pass` (race-engine §2). + HeatTransition::Finished + | HeatTransition::Aborted + | HeatTransition::Finalized + | HeatTransition::Reverted + | HeatTransition::Restarted + | HeatTransition::Discarded + | HeatTransition::Advanced => { + if let Some(running) = active.as_ref() { + if running.heat == heat { + if let Some(running) = active.take() { + running.stop( + #[cfg(feature = "live")] + connections, + #[cfg(feature = "live")] + event_id, + ); + } + } + } + // Open practice (open-practice format, Slice 1): **clear on stop**. Drop the in-memory + // per-channel accumulator when the open-practice heat reaches a terminal / abort / restart + // transition, then wake `/stream` so it re-folds the now-overlay-free log state (the + // console settles back onto the bare log — no stale laps frame). The `Finished` + // (Running → Unofficial) step is *kept* so the RD still sees the final practice laps + // before finalizing; the true terminals below clear it. A new heat/round becoming active + // also clears it (via `begin`). + // + // The overlay is **laps-only** now: the heat's phase/clock are always the real log's + // (this same `HeatStateChanged` was already appended and woke the stream), so the served + // phase follows the log to `Unofficial` here and to `Scheduled` on a `Restart` with no + // shadow-tracking. We therefore only need to clear the laps on the terminals and wake. + if state.open_practice().is_active(&heat) + && !matches!(transition, HeatTransition::Finished) + && state.open_practice().clear() + { + // Wake-on-clear: re-fold without the overlay so the laps drop immediately. + state.wake_streams(); + } + // Auto-official timer (marshaling Slice 5): the two transitions that land the heat in + // `Unofficial` — `Finished` (race-end) and `Reverted` (a finalized result re-opened) — + // (re)arm the protest window. The `cancel_for(&heat)` at the top already dropped any prior + // protest driver, so a `Revert` cleanly re-opens the window from the new `Unofficial` + // instant. A round with no protest window spawns an inert driver that returns immediately. + // The other transitions here (`Finalized`/`Aborted`/`Restarted`/`Discarded`/`Advanced`) + // leave `Unofficial`, so they were cancelled above and are not re-armed. + if matches!( + transition, + HeatTransition::Finished | HeatTransition::Reverted + ) { + HeatClock::install( + &mut clock.protest, + heat.clone(), + spawn_auto_official_driver(state, registry, event_id, heat.clone()), + ); + } + } + // Staged is pre-Running: the heat isn't emitting yet, but it is the moment to **tune** the + // device to the heat's assigned channels (race redesign Slice 4a — "the engine allocates, + // the adapter applies") and to **prepare** each RotorHazard timer for an instant start (Grid + // owns all timing). The Mock has no tune/prepare (the sim source ignores channels and has no + // staging — no-op); each selected RotorHazard timer's live connection emits `set_frequency` + // per node and zeroes RH's staging + resets it to READY. The tune plan is read from the + // heat's `HeatScheduled.frequencies` (empty ⇒ nothing to tune); the prepare runs regardless + // so RH is reset and its staging zeroed even for a heat with no explicit channel plan. + HeatTransition::Staged => { + #[cfg(feature = "live")] + { + let plan = tune_plan_of(state, &heat); + let seats = seats_of(state, registry, &heat); + for timer_id in selected_rh_timers(registry, timers, event_id) { + // Ready RH for an instant start at Grid's go: zero its staging hold/tones + reset + // to READY now, well before the Armed hold + tone fire. Retires the old at-go + // reset/stage race (the `STAGE_RESET_SETTLE` band-aid). + connections.prepare(event_id, &timer_id); + if !plan.is_empty() { + connections.tune(event_id, &timer_id, plan.clone()); + } + // Seat the heat's bound pilots onto their RH nodes (the laps-attribute fix): RH + // dismisses a crossing on a node with no seated pilot, so without this RH races an + // empty-pilot heat and records zero laps. Empty (an open-practice / unbound heat) + // is a no-op — the connection then runs in practice mode (RH records via the + // no-current-heat gate branch). + if !seats.is_empty() { + connections.seat(event_id, &timer_id, seats.clone()); + } + } + } + // Mock timers are a no-op: the synthetic source flies on no real channels. In a + // non-`live` build there is no RH connection at all, so `heat` is unused here. + #[cfg(not(feature = "live"))] + let _ = &heat; + } + // Armed: run the **start procedure** (heat-lifecycle Slice 2). The heat isn't emitting yet; + // the start driver logs the chosen delay (`HeatStarting`) and, after it, the auto + // `Armed → Running`. A manual `SkipCountdown` (or an abort) cancels this via `cancel_for` + // before it fires — see the top of this fn. + HeatTransition::Armed => { + HeatClock::install( + &mut clock.start, + heat.clone(), + spawn_start_driver(state, registry, event_id, heat), + ); + } + } +} + +/// The runtime-clock drivers in flight for the bridge (heat-lifecycle Slice 2), **keyed per +/// heat**: the `start` countdown for each heat in `Armed`, the `completion` clock for each in +/// `Running`, and the `protest` auto-official timer for each in `Unofficial`. Per-heat maps — +/// NOT single slots — because the windows genuinely overlap in normal operation (heat 2 +/// finishes while heat 1's protest window is still open): a single slot silently DETACHED the +/// older heat's task on overwrite, `cancel_for` could never find it again, and its orphaned +/// timer later force-finalized a heat the RD had already discarded or reverted. +#[derive(Default)] +struct HeatClock { + /// Start countdowns, per heat in `Armed` (appends `HeatStarting` then auto `Running`). + start: std::collections::HashMap>, + /// Completion clocks, per heat in `Running` (appends auto `Finished` on win + grace). + completion: std::collections::HashMap>, + /// Auto-official timers, per heat in `Unofficial` (marshaling Slice 5): when the round armed + /// a protest window, logs the deadline (`HeatFinalizing`) then appends the auto `Finalized`. + protest: std::collections::HashMap>, +} + +impl HeatClock { + /// Cancel any in-flight start/completion/protest driver belonging to `heat` (it just changed + /// state, so a pending auto-transition for the *old* state must not land). Drivers for other + /// heats are left running. Aborting a finished task is a harmless no-op. + fn cancel_for(&mut self, heat: &HeatId) { + if let Some(task) = self.start.remove(heat) { + task.abort(); + } + if let Some(task) = self.completion.remove(heat) { + task.abort(); + } + if let Some(task) = self.protest.remove(heat) { + task.abort(); + } + } + + /// Install `task` as `heat`'s driver in `slot`, aborting any previous task for the SAME heat + /// (a re-arm replaces; other heats' drivers are untouched — the single-slot detach bug). + fn install( + slot: &mut std::collections::HashMap>, + heat: HeatId, + task: JoinHandle<()>, + ) { + if let Some(previous) = slot.insert(heat, task) { + previous.abort(); + } + } +} + +/// A heat currently emitting passes: which heat, the synthetic-source task(s) driving its selected +/// **Mock** timers (issue #73 — an event may select several), and (under `live`) the selected +/// **RotorHazard** timers armed onto their persistent connections for this heat (#105). +struct ActiveHeat { + heat: HeatId, + handles: Vec>, + /// The active-source gate for this heat (issue #112): the bridge re-evaluates the active source + /// each poll and stores it here, and every source's sink reads it to gate its appends — so only + /// the active source feeds and a primary drop fails over live. + gate: ActiveSourceGate, + /// The RotorHazard timers armed onto their live connections for this heat, disarmed when the + /// heat leaves `Running` (the connection stays alive). + #[cfg(feature = "live")] + armed_rh: Vec, +} + +impl ActiveHeat { + /// Whether every synthetic-source task has finished — the slot can be reaped. RotorHazard + /// connections are persistent (not per-heat tasks), so they don't gate reaping; a finished + /// Mock run still leaves any armed RH connection live until the heat leaves `Running`. + fn is_finished(&self) -> bool { + self.handles.iter().all(|h| h.is_finished()) + } + + /// Stop this heat's emission: abort every in-flight Mock source task, and **disarm** each armed + /// RotorHazard timer (stop/clear its race but keep the connection alive). Called when the heat + /// leaves `Running` or is superseded by a newer running heat. + fn stop( + &self, + #[cfg(feature = "live")] connections: &RhConnections, + #[cfg(feature = "live")] event_id: &EventId, + ) { + for h in &self.handles { + h.abort(); + } + #[cfg(feature = "live")] + for timer_id in &self.armed_rh { + connections.disarm(event_id, timer_id); + } + } +} + +/// Read the log tail from `cursor`, returning `(offset, event)` pairs. A thin wrapper over +/// the shared log handle so the bridge can poll without reaching into the server internals. +/// The heats caught MID-FLIGHT by a Director restart, each paired with the SYNTHETIC +/// transition that re-enters the normal `handle_transition` path for its current state: +/// `Armed` → the start countdown re-arms; `Running` → sources + the completion clock re-arm +/// (the race still auto-ends); `Unofficial` → the protest auto-official timer re-arms. +/// Everything else (Scheduled / Staged / Final / …) needs no runtime driver. +fn in_flight_heats(events: &[Event]) -> Vec<(HeatId, HeatTransition)> { + let mut seen = std::collections::BTreeSet::new(); + let mut in_flight = Vec::new(); + for event in events { + let heat = match event { + Event::HeatScheduled { heat, .. } | Event::HeatStateChanged { heat, .. } => heat, + _ => continue, + }; + if !seen.insert(heat.clone()) { + continue; + } + use gridfpv_engine::heat::HeatState; + let synthetic = match gridfpv_engine::heat::heat_state(events, heat) { + Some(HeatState::Armed) => HeatTransition::Armed, + Some(HeatState::Running) => HeatTransition::Running, + Some(HeatState::Unofficial) => HeatTransition::Finished, + _ => continue, + }; + in_flight.push((heat.clone(), synthetic)); + } + in_flight +} + +fn read_tail(state: &AppState, cursor: Offset) -> Result, SourceError> { + let log = state + .log() + .lock() + .map_err(|_| SourceError("the event log lock was poisoned".into()))? + .read_from(cursor) + .map_err(|e| SourceError(e.to_string()))?; + Ok(log.into_iter().map(|s| (s.offset, s.event)).collect()) +} + +/// The lineup of `heat` from its most recent `HeatScheduled` in the log, or `None` if the +/// heat was never scheduled (or the read failed). +fn lineup_of(state: &AppState, heat: &HeatId) -> Option> { + let stored = state.log().lock().ok()?.read_all().ok()?; + let mut lineup = None; + for s in stored { + if let Event::HeatScheduled { + heat: h, lineup: l, .. + } = s.event + { + if &h == heat { + lineup = Some(l); + } + } + } + lineup +} + +/// The per-pilot frequency assignment of `heat` from its most recent `HeatScheduled` (race redesign +/// Slice 4a), mapped onto **node indices** for the RH `set_frequency` tune: node `n` runs +/// `lineup[n]`, so a competitor's assigned MHz is applied to the node at its lineup position. A heat +/// with no assigned frequencies (a sim/un-channelled heat) yields an empty plan (no tuning). +#[cfg(feature = "live")] +fn tune_plan_of(state: &AppState, heat: &HeatId) -> Vec<(u64, u16)> { + let Some(stored) = state.log().lock().ok().and_then(|g| g.read_all().ok()) else { + return Vec::new(); + }; + let mut plan = Vec::new(); + for s in stored { + if let Event::HeatScheduled { + heat: h, + lineup, + frequencies, + label: None, + .. + } = s.event + { + if &h == heat { + // Map each (competitor, mhz) to the competitor's node seat (its lineup index). + plan = frequencies + .into_iter() + .filter_map(|(competitor, mhz)| { + lineup + .iter() + .position(|c| *c == competitor) + .map(|node| (node as u64, mhz)) + }) + .collect(); + } + } + } + plan +} + +/// The heat's **node→pilot seating** for RotorHazard (the laps-attribute fix): one +/// `(node_index, callsign)` per **bound** node of `heat`, read from the heat's lineup (its durable +/// `HeatScheduled` bind — node `n` runs `lineup[n]`). +/// +/// `lineup[n]` is the **pilot ref** for node `n` (the round engine builds the field as +/// `CompetitorRef(pilot_id)`), so each bound node resolves to its pilot's **callsign** via the +/// directory (CLAUDE.md: resolve a ref to its friendly name from a durable source, never print the +/// raw id). An open-practice / unchannelled heat seats per **channel** as `node-{i}` refs (no bound +/// pilot) — those are skipped here, leaving an empty plan (RH then races in practice mode). A pilot +/// ref that does not resolve falls back to the raw ref string as a last resort so the node is still +/// seated (RH records there) rather than dropped. +#[cfg(feature = "live")] +fn seats_of(state: &AppState, registry: &EventRegistry, heat: &HeatId) -> Vec<(u64, String)> { + let Some(lineup) = lineup_of(state, heat) else { + return Vec::new(); + }; + let pilots = registry.pilots(); + let mut seats = Vec::new(); + for (node, competitor) in lineup.into_iter().enumerate() { + // An open-practice seat (`node-{i}`) names a channel, not a bound pilot: leave it unseated. + if competitor + .0 + .strip_prefix("node-") + .is_some_and(|s| s.parse::().is_ok()) + { + continue; + } + let pilot_id = gridfpv_server::scope::PilotId(competitor.0.clone()); + let callsign = pilots + .get(&pilot_id) + .map(|p| p.callsign) + .unwrap_or(competitor.0); + seats.push((node as u64, callsign)); + } + seats +} + +// --- the runtime clock (heat-lifecycle redesign, Slice 2) ----------------------------- +// +// The clock is a **runtime input**, exactly like an RD button press: it never computes a +// transition from wall-clock *inside the fold* — it appends **logged events** that the pure +// engine/projection folds like any other. Two auto-transitions are driven here: +// +// * **start** — when a heat enters `Armed`, the runtime reads the round's `start_procedure`, +// picks the randomized delay **once** (RNG only here, at emission time), writes it to the log +// as `Event::HeatStarting { delay_ms }` (so the console can cue the tone and a replay reads the +// same delay), then appends `HeatStateChanged { Running }` after that delay. +// * **completion** — while a heat is `Running`, the runtime evaluates the round's win condition +// over the running passes (a *pure* predicate, `race_end_reached`), and once the race-end +// criterion is met it holds the configured **grace window** for trailing pilots, then appends +// `HeatStateChanged { Finished }`. +// +// Wall-clock (tokio time) decides only *when* the runtime appends; the delay and the criterion are +// facts in the log, so the replay is deterministic (race-engine.html §6). + +use gridfpv_engine::heat::{GraceWindow, ProtestWindow}; +use gridfpv_engine::scoring::race_end_reached; +use gridfpv_server::control_handler::open_protest_count; +use gridfpv_server::events::{RoundDef, StartProcedure}; + +/// How often the completion driver re-evaluates the win condition over the running passes. +const COMPLETION_POLL: Duration = Duration::from_millis(100); + +/// The per-heat runtime config the clock needs: the round's start procedure, win condition, and +/// grace window. Resolved from the heat's `HeatScheduled.round` against the event meta; a heat with +/// no round (a sim / free-text heat) uses the documented defaults so the clock still drives it. +struct HeatClockConfig { + start_procedure: StartProcedure, + win_condition: gridfpv_engine::scoring::WinCondition, + grace_window: GraceWindow, + /// The round's optional **time limit** in seconds (open-practice refinement): when set, the + /// completion driver auto-ends the heat (`Running → Unofficial`) once its elapsed running time + /// reaches this — independent of the win condition. `None` ⇒ the heat ends only on its win + /// condition (or the RD's `ForceEnd`). Carried for every heat but acted on for the open-practice + /// case (a practice with no win condition relies on it). + time_limit_secs: Option, + /// The round's **protest window** (marshaling Slice 5): when [`ProtestWindow::After`], the + /// auto-official driver arms the auto-finalize timer once the heat enters `Unofficial`. The + /// default [`ProtestWindow::Off`] means manual finalize only (no auto-official driver work). + protest_window: ProtestWindow, +} + +/// Resolve the clock config for `heat`: find the heat's most-recent `HeatScheduled.round`, look that +/// round up in the event meta, and read its `start_procedure` / `win_condition` / `grace_window`. A +/// heat with no round tag, or whose round is gone, falls back to the round defaults (a sane +/// randomized start delay + a bounded grace + a Timed win condition is *not* assumed — see below). +fn heat_clock_config( + state: &AppState, + registry: &EventRegistry, + event_id: &EventId, + heat: &HeatId, +) -> HeatClockConfig { + let round_id = round_of_heat(state, heat); + let round: Option = round_id.and_then(|rid| { + registry + .rounds_of(event_id) + .and_then(|rounds| rounds.into_iter().find(|r| r.id == rid)) + }); + match round { + Some(r) => HeatClockConfig { + start_procedure: r.start_procedure, + win_condition: r.win_condition, + grace_window: r.grace_window, + time_limit_secs: r.time_limit_secs, + protest_window: r.protest_window, + }, + // No round (sim/free-text): default the start procedure + grace so the auto-start still + // fires; the win condition defaults to the sim's `FirstToLaps` over the sim lap count so a + // round-less sim heat still auto-completes (the sim emits a fixed number of laps). No time + // limit (a round-less heat has no practice duration). + None => HeatClockConfig { + start_procedure: StartProcedure::default(), + win_condition: default_sim_win_condition(), + grace_window: gridfpv_server::events::default_grace_window(), + time_limit_secs: None, + // A round-less heat (sim/free-text) has no protest window: manual finalize only. + protest_window: ProtestWindow::Off, + }, + } +} + +/// The win condition a **round-less** heat (a sim / free-text heat with no `RoundDef`) auto-completes +/// under: first-to-`DEFAULT_SIM_LAPS` laps. The sim emits a holeshot + `DEFAULT_SIM_LAPS` laps per +/// pilot, so the leader reaching that count is the natural completion point — letting the clock drive +/// a bare sim heat all the way to `Unofficial` without a configured round. +fn default_sim_win_condition() -> gridfpv_engine::scoring::WinCondition { + gridfpv_engine::scoring::WinCondition::FirstToLaps { + n: DEFAULT_SIM_LAPS, + } +} + +/// Whether `heat` is an **open-practice** heat (open-practice format, Slice 1): its most-recent +/// `HeatScheduled.round` resolves to a round that [`is_open_practice`](gridfpv_server::round_engine::is_open_practice). +/// +/// The bridge uses this on a `Running` transition to decide whether to route the heat's passes into +/// the in-memory per-channel accumulator (open practice) rather than the log. A heat with no round +/// tag, or a round that is not open-practice, returns `false` (the normal logged path). +fn is_open_practice_heat( + state: &AppState, + registry: &EventRegistry, + event_id: &EventId, + heat: &HeatId, +) -> bool { + let Some(round_id) = round_of_heat(state, heat) else { + return false; + }; + registry + .rounds_of(event_id) + .and_then(|rounds| rounds.into_iter().find(|r| r.id == round_id)) + .is_some_and(|r| gridfpv_server::round_engine::is_open_practice(&r)) +} + +/// The `RoundId` tag on `heat`'s most-recent `HeatScheduled`, if any. +fn round_of_heat(state: &AppState, heat: &HeatId) -> Option { + let stored = state.log().lock().ok()?.read_all().ok()?; + let mut round = None; + for s in stored { + if let Event::HeatScheduled { + heat: h, round: r, .. + } = s.event + { + if &h == heat { + round = r; + } + } + } + round +} + +/// Pick the randomized start delay for a `start_procedure`, in milliseconds — the **one** place the +/// runtime rolls the dice (heat-lifecycle Slice 2). Seeded from the wall clock so each real arm is +/// unpredictable; the chosen value is then logged as a fact, so the *replay* never calls this again. +fn pick_start_delay_ms(procedure: &StartProcedure) -> u32 { + match procedure { + StartProcedure::RandomizedDelay { + min_delay_ms, + max_delay_ms, + .. + } => { + let (lo, hi) = (*min_delay_ms, *max_delay_ms); + // Defensively clamp a mis-ordered pair to a point delay rather than panicking. + if hi <= lo { + return lo; + } + let span = (hi - lo) as u64 + 1; + lo + (runtime_rng() % span) as u32 + } + } +} + +/// A tiny wall-clock-seeded RNG draw (a `u64`), used **only** at emission time in the runtime to pick +/// the start delay — never in the engine/projection fold. Avoids pulling in the `rand` crate for one +/// draw: a SplitMix64 step over the current monotonic-ish nanos is plenty random for a start hold. +fn runtime_rng() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0x9E37_79B9_7F4A_7C15); + // SplitMix64 finalizer — decorrelates successive nanos so close-together arms don't draw alike. + let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +/// The race-start origin for `heat`'s win-condition evaluation: the source time of the heat's +/// **first lap-gate pass** while `Running`, matching how `completed_heats` derives `race_start`. The +/// passes carry the source clock; the first crossing opens the shared race clock. `None` until the +/// first pass lands (no crossing yet ⇒ the race-end criterion cannot be met). +fn race_start_of(passes: &[Pass]) -> Option { + passes.first().map(|p| p.at) +} + +/// The grace hold, as a wall-clock `Duration`, for the completion driver: how long to keep the heat +/// `Running` for trailing pilots after the win condition is met (heat-lifecycle Slice 2). An open +/// [`GraceWindow::UntilScored`] would never auto-fire, so it is treated as **zero** here (the RD +/// would `ForceEnd` / `Finalize` such a round); a bounded [`GraceWindow::Duration`] maps its source +/// microseconds to a real-time hold of the same length. +fn grace_hold(grace: GraceWindow) -> Duration { + match grace { + GraceWindow::Duration { micros } if micros > 0 => Duration::from_micros(micros as u64), + _ => Duration::ZERO, + } +} + +/// Spawn the **start driver** for a heat that just entered `Armed` (heat-lifecycle Slice 2). +/// +/// Reads the heat's start procedure, picks the randomized delay **once**, appends the +/// `Event::HeatStarting { delay_ms }` fact immediately (so the console can cue the start tone and a +/// replay reads this exact delay), then — after `delay_ms` of real time — appends the +/// `HeatStateChanged { Running }` auto-transition through the shared append path (waking `/stream`). +/// The returned task is cancelled by the bridge if the heat leaves `Armed` before it fires (an abort +/// / restart / a manual `SkipCountdown`), so a superseded countdown never appends a stale `Running`. +fn spawn_start_driver( + state: &AppState, + registry: &EventRegistry, + event_id: &EventId, + heat: HeatId, +) -> JoinHandle<()> { + let config = heat_clock_config(state, registry, event_id, &heat); + let delay_ms = pick_start_delay_ms(&config.start_procedure); + let state = state.clone(); + let spawn_watermark = read_tail(&state, 0) + .ok() + .map(|events| { + let events: Vec = events.into_iter().map(|(_, e)| e).collect(); + latest_transition_offset(&events, &heat) + }) + .unwrap_or(None); + tokio::spawn(async move { + // The chosen delay is logged as a fact *before* the hold, so a console cueing the tone and a + // replay both read it; only the append timing below uses wall-clock. + if let Err(e) = state.append( + Event::HeatStarting { + heat: heat.clone(), + delay_ms, + }, + None, + ) { + eprintln!("gridfpv: start driver could not log HeatStarting: {e:?}"); + return; + } + tokio::time::sleep(Duration::from_millis(delay_ms as u64)).await; + // Auto-advance Armed → Running — but RE-CHECK the heat is still Armed at fire time, + // under the command serialization lock. The bridge's cancel is poll-paced (~150ms), so + // a manual SkipCountdown/Abort landing just before this fired used to race a duplicate + // (or stale) `Running` into the log. + let still_armed = { + let h = heat.clone(); + move |events: &[Event]| { + gridfpv_engine::heat::heat_state(events, &h) + == Some(gridfpv_engine::heat::HeatState::Armed) + // …and it is the SAME arming (an abort + re-arm during this hold would + // read Armed again — but with a NEW countdown; this one is superseded). + && latest_transition_offset(events, &h) == spawn_watermark + } + }; + match state.append_checked( + Event::HeatStateChanged { + heat, + transition: HeatTransition::Running, + }, + None, + still_armed, + ) { + Ok(_) => {} + Err(e) => eprintln!("gridfpv: start driver could not append Running: {e:?}"), + } + }) +} + +/// Spawn the **completion driver** for a heat that just entered `Running` (heat-lifecycle Slice 2). +/// +/// Polls the heat's running passes every [`COMPLETION_POLL`]; once the round's win condition is met +/// (the pure [`race_end_reached`] over the passes + the race-start time), it holds the configured +/// **grace window** for trailing pilots, then appends the `HeatStateChanged { Finished }` +/// auto-transition (the `Running → Unofficial` step). Cancelled by the bridge if the heat leaves +/// `Running` first (an abort / restart / a manual `ForceEnd`), so a superseded heat never appends a +/// stale `Finished`. A round whose win condition has no intrinsic end (a bare qual — see +/// [`race_end_reached`]) simply never fires here; the RD ends it with `ForceEnd`. +/// +/// **Timed-window fallback:** a [`Timed`](gridfpv_engine::scoring::WinCondition::Timed) round's +/// pass-based criterion needs a crossing at/after the cutoff to fire — if every pilot lands at the +/// buzzer, no such pass ever arrives. The driver therefore also closes a Timed heat on the wall +/// clock once the window plus the grace hold has elapsed (anchored to the first observed pass, or +/// to race-go when nobody ever crossed), so a timed heat always ends on its own. +/// +/// **Open-practice time limit (open-practice refinement):** when the round carries a +/// [`time_limit_secs`](gridfpv_server::events::RoundDef::time_limit_secs), the driver auto-ends the +/// heat once its elapsed running time reaches the limit — **independent of the win condition** (an +/// open-practice heat does no scoring and its passes are never logged, so the win-condition path +/// never fires for it; the time limit is the only end condition). The elapsed clock starts when the +/// heat enters `Running` (this driver's spawn), so it is the same deterministic, logged transition +/// the other autos key off — a 1-hour practice ends itself an hour after Start. With no limit set, +/// only the win-condition path can fire (the RD ends an open practice manually). +fn spawn_completion_driver( + state: &AppState, + registry: &EventRegistry, + event_id: &EventId, + heat: HeatId, +) -> JoinHandle<()> { + let config = heat_clock_config(state, registry, event_id, &heat); + // The Timed window, for the wall-clock fallback below. Open practice is EXCLUDED: its round + // stores an inert default win condition that must never be consulted — its `time_limit_secs` + // is the only end condition (the branch above). + let timed_window = match config.win_condition { + gridfpv_engine::scoring::WinCondition::Timed { window_micros } + if !is_open_practice_heat(state, registry, event_id, &heat) => + { + Some(Duration::from_micros(window_micros.max(0) as u64)) + } + _ => None, + }; + let state = state.clone(); + // The run this driver belongs to, as a spawn-time watermark (the heat's latest transition + // offset) — the fire-time recheck stands down if ANY transition landed since. + let spawn_watermark = read_tail(&state, 0) + .ok() + .map(|events| { + let events: Vec = events.into_iter().map(|(_, e)| e).collect(); + latest_transition_offset(&events, &heat) + }) + .unwrap_or(None); + // The running clock origin: the moment the heat entered `Running` (this spawn). The time-limit + // deadline, when set, is measured from here — a deterministic wall-clock span (a test drives it + // with a short limit; production with the practice duration). + let running_since = tokio::time::Instant::now(); + let time_limit = config + .time_limit_secs + .map(|secs| Duration::from_secs(secs as u64)); + let mut ticker = tokio::time::interval(COMPLETION_POLL); + tokio::spawn(async move { + // The wall-clock instant this driver first OBSERVED a running pass — the fallback's + // race-clock anchor. Observation lags the true crossing by up to a poll tick (+ transport), + // so a deadline anchored here is never *early* relative to the pass-anchored cutoff. + let mut first_pass_seen: Option = None; + loop { + ticker.tick().await; + // Time-limit auto-end (open-practice refinement): once the elapsed running time reaches + // the practice duration, close the heat regardless of any win condition or passes — the + // only end condition for an open-practice heat (whose passes are never logged, so the + // win-condition branch below never fires for it). Logged like the other autos. + if let Some(limit) = time_limit { + if running_since.elapsed() >= limit { + if let Err(e) = append_finished_if_running(&state, &heat, spawn_watermark) { + eprintln!( + "gridfpv: completion driver could not append time-limit Finished: {e:?}" + ); + } + return; + } + } + let passes = heat_running_passes(&state, &heat); + if first_pass_seen.is_none() && !passes.is_empty() { + first_pass_seen = Some(tokio::time::Instant::now()); + } + // Timed-window wall-clock fallback: `race_end_reached` for a Timed round only fires + // when a lap-gate pass lands AT/AFTER the cutoff — if nobody crosses again after the + // window ends (pilots land at the buzzer; a short time trial), the pass-based path + // never triggers and the heat would stay `Running` forever. Once the window PLUS the + // grace hold has elapsed on the wall clock — measured from the race-clock origin (the + // first observed pass; race-go when nobody ever crossed) — close the heat. Grace-window + // crossings before this deadline still land in the log and score normally; a + // post-cutoff crossing still ends the heat earlier via the pass-based path below. + if let Some(window) = timed_window { + let anchor = first_pass_seen.unwrap_or(running_since); + if anchor.elapsed() >= window + grace_hold(config.grace_window) { + if let Err(e) = append_finished_if_running(&state, &heat, spawn_watermark) { + eprintln!( + "gridfpv: completion driver could not append timed-window Finished: {e:?}" + ); + } + return; + } + } + let Some(race_start) = race_start_of(&passes) else { + continue; // no crossing yet — the race clock hasn't opened + }; + if race_end_reached(&passes, config.win_condition, race_start) { + // The race-end criterion is met: hold the grace window for late crossings, then + // close the race. The hold is wall-clock; the *decision* was pure. + tokio::time::sleep(grace_hold(config.grace_window)).await; + if let Err(e) = append_finished_if_running(&state, &heat, spawn_watermark) { + eprintln!("gridfpv: completion driver could not append Finished: {e:?}"); + } + return; + } + } + }) +} + +/// The offset of `heat`'s LATEST `HeatStateChanged` — the spawn-time watermark a driver +/// captures so its fire-time recheck can tell "still the SAME state" from "the same state +/// AGAIN": a heat that was Force-Ended and re-raced during a driver's hold is Running like +/// before, but it is a NEW run and the stale driver must stand down (state alone can't tell). +fn latest_transition_offset(events: &[Event], heat: &HeatId) -> Option { + events + .iter() + .enumerate() + .filter_map(|(i, e)| match e { + Event::HeatStateChanged { heat: h, .. } if h == heat => Some(i), + _ => None, + }) + .next_back() +} + +/// Append `Finished` for `heat` iff it is STILL `Running` at fire time — the completion +/// driver's checked append (under the command serialization lock). The bridge's cancel is +/// poll-paced, so a ForceEnd/Abort landing just before an expiring clock used to race a +/// duplicate/stale `Finished` into the log. +fn append_finished_if_running( + state: &AppState, + heat: &HeatId, + spawn_watermark: Option, +) -> Result<(), gridfpv_server::error::ProtocolError> { + let h = heat.clone(); + state + .append_checked( + Event::HeatStateChanged { + heat: heat.clone(), + transition: HeatTransition::Finished, + }, + None, + move |events| { + gridfpv_engine::heat::heat_state(events, &h) + == Some(gridfpv_engine::heat::HeatState::Running) + // …and it is the SAME run this driver was spawned for: any transition + // since spawn (ForceEnd + Restart + re-race in one hold) supersedes it. + && latest_transition_offset(events, &h) == spawn_watermark + }, + ) + .map(|_| ()) +} + +/// Server wall-clock time in **microseconds** since the Unix epoch — the basis for the auto-official +/// deadline (marshaling Slice 5). Matches the server's own `recorded_at` stamping (the `Finalize` the +/// driver appends is stamped from the same clock), so `at` and the eventual transition's +/// `recorded_at` agree to within the hold's scheduling jitter. +fn now_micros() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_micros() as i64) + .unwrap_or(0) +} + +/// Spawn the **auto-official driver** for a heat that just entered `Unofficial` (marshaling Slice 5). +/// +/// Reads the heat's round [`ProtestWindow`]. With the default [`ProtestWindow::Off`] there is no +/// auto-official timer — the driver returns immediately and the heat stays provisional until the RD +/// finalizes manually (today's behaviour). With [`ProtestWindow::After { micros }`] it: +/// 1. logs the **deadline** as a fact — `Event::HeatFinalizing { at: now + micros }` — so the +/// console can render a live "auto-official in M:SS" countdown and a replay reads the same +/// deadline (mirroring how the start driver logs `HeatStarting`); +/// 2. holds `micros` of real time; +/// 3. appends the auto `HeatStateChanged { Finalized }` (the `Unofficial → Final` step) — +/// **unless the heat has an open protest** (issue #338, below). +/// +/// **The open-protest gate (issue #338).** The manual `Finalize` command is gated on open protests +/// (release-hardening P1-4): a filed, unresolved protest means the result is still contested. The +/// auto-official append does not run through the command handler, so it checks the *same shared +/// predicate* ([`open_protest_count`]) at window expiry. If protests are open when the window +/// elapses, the driver **stands down**: it appends nothing and leaves the heat `Unofficial` for +/// the RD. There is deliberately **no retry**: a protest pulls a human into the loop, and the RD's +/// follow-up may be several rulings (resolve, then a penalty, then finalize) — an auto-finalize +/// firing at the surprising instant the last protest resolves could race those. The RD's manual +/// `Finalize` (one click, re-checked by the same gate on the command path) closes the heat. +/// +/// The returned task is cancelled by the bridge (`cancel_for`) the moment the heat leaves +/// `Unofficial` — a manual early `Finalize`, a `Revert`, an abort/restart — so a superseded window +/// never appends a stale `Finalized`. A `Revert` back to `Unofficial` re-arms a fresh driver (a new +/// window from the new race-end instant). This is an **additive auto-finalize**, never a gate: the +/// RD's manual `Finalize` stays available and simply pre-empts the timer. +fn spawn_auto_official_driver( + state: &AppState, + registry: &EventRegistry, + event_id: &EventId, + heat: HeatId, +) -> JoinHandle<()> { + let config = heat_clock_config(state, registry, event_id, &heat); + let state = state.clone(); + let spawn_watermark = read_tail(&state, 0) + .ok() + .map(|events| { + let events: Vec = events.into_iter().map(|(_, e)| e).collect(); + latest_transition_offset(&events, &heat) + }) + .unwrap_or(None); + tokio::spawn(async move { + let ProtestWindow::After { micros } = config.protest_window else { + // Off (the default): no auto-official timer — manual finalize only. Nothing to do. + return; + }; + // A non-positive window auto-finalizes immediately (defensive — the form clamps to ≥ 0). + let hold = Duration::from_micros(micros.max(0) as u64); + let deadline = now_micros().saturating_add(micros.max(0)); + // Log the deadline as a fact *before* the hold, so the console can count down to it and a + // replay reads the same instant; only the append timing below uses wall-clock. + if let Err(e) = state.append( + Event::HeatFinalizing { + heat: heat.clone(), + at: deadline, + }, + None, + ) { + eprintln!("gridfpv: auto-official driver could not log HeatFinalizing: {e:?}"); + return; + } + tokio::time::sleep(hold).await; + // The expired window must not finalize over an **open protest** (issue #338): check the + // same predicate the manual `Finalize` command is gated on (P1-4). A protest filed during + // (or before) the window means the result is still contested — stand down and leave the + // heat `Unofficial` for the RD (see the doc comment for why there is no retry). A log read + // failure also stands down: fail closed, never finalize blind. + let open = state + .log() + .lock() + .ok() + .and_then(|g| g.read_all().ok()) + .map(|stored| { + let events: Vec = stored.into_iter().map(|s| s.event).collect(); + open_protest_count(&events, &heat) + }); + match open { + Some(0) => {} + Some(n) => { + eprintln!( + "gridfpv: auto-official window for heat {:?} expired with {n} open protest(s); \ + leaving it Unofficial for the RD to resolve and finalize", + heat.0 + ); + return; + } + None => { + eprintln!( + "gridfpv: auto-official driver could not read the log for heat {:?}; \ + leaving it Unofficial", + heat.0 + ); + return; + } + } + // Auto-finalize Unofficial → Final — RE-CHECKED at fire time under the command + // serialization lock: the heat must STILL be Unofficial with no open protest at the + // instant of the append. The bridge's cancel is poll-paced, so a Revert (or a fresh + // protest) landing just before the window expired used to race a stale `Finalized` in + // — instantly re-finalizing the heat the RD had just re-opened. + let h = heat.clone(); + let gate = move |events: &[Event]| { + gridfpv_engine::heat::heat_state(events, &h) + == Some(gridfpv_engine::heat::HeatState::Unofficial) + // …the SAME provisional window (a revert→finalize→revert chain during the + // hold reads Unofficial again — with a NEW window; this one is superseded)… + && latest_transition_offset(events, &h) == spawn_watermark + && open_protest_count(events, &h) == 0 + }; + match state.append_checked( + Event::HeatStateChanged { + heat, + transition: HeatTransition::Finalized, + }, + None, + gate, + ) { + Ok(_) => {} + Err(e) => eprintln!("gridfpv: auto-official driver could not append Finalized: {e:?}"), + } + }) +} + +/// The lap-gate passes attributed to `heat`'s current run: every lap-gate [`Pass`] in the log since +/// the heat last entered `Running`. The completion driver scores over exactly the running window, so +/// an earlier aborted run's passes don't count toward this run's win condition. +fn heat_running_passes(state: &AppState, heat: &HeatId) -> Vec { + let Some(stored) = state.log().lock().ok().and_then(|g| g.read_all().ok()) else { + return Vec::new(); + }; + // Walk the log: the most recent `Running` for this heat opens the window; collect lap-gate + // passes after it until the heat leaves Running. (Passes carry no heat id — while a heat is + // Running it is the only one consuming, mirroring the bridge's single-active-heat rule.) + let mut running = false; + let mut passes = Vec::new(); + for s in stored { + match s.event { + Event::HeatStateChanged { + heat: ref h, + transition, + } if h == heat => match transition { + HeatTransition::Running => { + running = true; + passes.clear(); // a fresh run resets the window + } + _ => running = false, + }, + // A tagged pass belongs to its stamped heat regardless of the positional cursor + // (same rule as the server's window folds); an untagged (legacy) pass keeps the + // positional rule. Either way only while this heat's run window is open. + Event::Pass(p) + if running && p.gate.is_lap_gate() && p.heat.as_ref().is_none_or(|h| h == heat) => + { + passes.push(p) + } + _ => {} + } + } + passes +} + +fn parse_env_u32(key: &str) -> Option { + std::env::var(key).ok()?.trim().parse().ok() +} + +fn parse_env_u64(key: &str) -> Option { + std::env::var(key).ok()?.trim().parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + use gridfpv_events::RoundId; + use gridfpv_server::events::{EventRegistry, PRACTICE_EVENT_ID}; + use gridfpv_server::live_state::live_state; + use gridfpv_server::timers::{ + CreateTimerRequest, MOCK_TIMER_ID, TimerId, TimerKind, TimerStatus, UpdateTimerRequest, + }; + use tokio::time::{Instant, sleep, timeout}; + + /// The Practice event id every bridge test drives (its in-memory log + default `["mock"]` + /// selection). + fn practice() -> EventId { + EventId(PRACTICE_EVENT_ID.to_string()) + } + + /// Build a fresh registry and retune its built-in **Mock** to a fast pace (`lap_ms`) and + /// the wanted `laps`, so the whole heat runs in a few ms. Practice defaults to selecting the + /// Mock, so the bridge over Practice drives this retuned source. The bridge polls at + /// [`POLL_INTERVAL`], which dominates start-up latency, so tests keep total laps small. + fn fast_registry(laps: u32, lap_ms: u64) -> EventRegistry { + let registry = EventRegistry::new(None).unwrap(); + registry + .timers() + .update( + &TimerId(MOCK_TIMER_ID.to_string()), + &UpdateTimerRequest { + name: None, + kind: Some(TimerKind::Mock { laps, lap_ms }), + ..Default::default() + }, + ) + .unwrap(); + registry + } + + /// Spawn the selection-aware bridge for `registry`'s Practice event, returning the bridge + /// handle and Practice's `AppState` (the same log the bridge polls), so a test appends the + /// schedule/transition events the bridge reacts to. + fn spawn_bridge_for(registry: &EventRegistry) -> (JoinHandle<()>, AppState) { + let state = registry.resolve(&practice()).unwrap(); + let timers = registry.timers(); + let adapter = AdapterId(SIM_ADAPTER.to_string()); + let reg = registry.clone(); + let bridge_state = state.clone(); + #[cfg(feature = "live")] + let connections = RhConnections::new(); + let handle = tokio::spawn(async move { + run_bridge( + bridge_state, + reg, + timers, + practice(), + adapter, + #[cfg(feature = "live")] + connections, + ) + .await + }); + (handle, state) + } + + fn read_all_events(state: &AppState) -> Vec { + state + .log() + .lock() + .unwrap() + .read_all() + .unwrap() + .into_iter() + .map(|s| s.event) + .collect() + } + + fn count_passes(events: &[Event]) -> usize { + events + .iter() + .filter(|e| matches!(e, Event::Pass(p) if p.gate.is_lap_gate())) + .count() + } + + /// Poll until `cond` over the current log holds, or fail after `deadline`. Keeps the + /// tests deterministic-by-condition rather than by fixed sleeps. + async fn wait_until( + state: &AppState, + deadline: Duration, + mut cond: impl FnMut(&[Event]) -> bool, + ) { + let start = Instant::now(); + loop { + let events = read_all_events(state); + if cond(&events) { + return; + } + if start.elapsed() > deadline { + panic!( + "condition not met within {deadline:?}; log has {} events", + events.len() + ); + } + sleep(Duration::from_millis(5)).await; + } + } + + #[tokio::test] + async fn running_heat_emits_laps_for_every_lineup_member() { + let laps = 3u32; + let registry = fast_registry(laps, 1); + let (bridge, state) = spawn_bridge_for(®istry); + + // Schedule a heat and drive it to Running via the (shared) log — exactly what the + // control path appends. + let heat = HeatId("q-1".into()); + state + .append( + Event::HeatScheduled { + heat: heat.clone(), + lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + state + .append( + Event::HeatStateChanged { + heat: heat.clone(), + transition: HeatTransition::Running, + }, + None, + ) + .unwrap(); + + // Each pilot emits a holeshot + `laps` laps = laps+1 passes; two pilots => 2*(laps+1). + let expected = 2 * (laps as usize + 1); + timeout( + Duration::from_secs(5), + wait_until(&state, Duration::from_secs(5), move |events| { + count_passes(events) >= expected + }), + ) + .await + .expect("the sim should emit all passes well within the timeout"); + + let events = read_all_events(&state); + assert_eq!( + count_passes(&events), + expected, + "exactly holeshot+laps per pilot" + ); + + // (b) live_state / PilotProgress shows laps for each pilot. + let ls = live_state(&events); + assert_eq!(ls.progress.len(), 2); + for p in &ls.progress { + assert!( + p.laps_completed >= 1, + "pilot {:?} should have completed laps, got {}", + p.competitor, + p.laps_completed + ); + } + + bridge.abort(); + } + + #[tokio::test] + async fn finished_transition_stops_emission() { + // Many laps at a slightly slower pace so the heat is still mid-emission when we + // Finish it — proving the Finish actually cancels in-flight emission. + let registry = fast_registry(50, 3); + let (bridge, state) = spawn_bridge_for(®istry); + + let heat = HeatId("q-1".into()); + state + .append( + Event::HeatScheduled { + heat: heat.clone(), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + state + .append( + Event::HeatStateChanged { + heat: heat.clone(), + transition: HeatTransition::Running, + }, + None, + ) + .unwrap(); + + // Let a few passes land, then Finish. + wait_until(&state, Duration::from_secs(5), |events| { + count_passes(events) >= 2 + }) + .await; + state + .append( + Event::HeatStateChanged { + heat: heat.clone(), + transition: HeatTransition::Finished, + }, + None, + ) + .unwrap(); + + // After the bridge observes Finished (a poll interval) and the in-flight task is + // aborted, the pass count must settle. Sample, wait past several poll/lap cycles, + // sample again: no further passes. + sleep(POLL_INTERVAL * 3).await; + let settled = count_passes(&read_all_events(&state)); + sleep(POLL_INTERVAL * 4).await; + let after = count_passes(&read_all_events(&state)); + assert_eq!( + after, settled, + "no passes should be appended after Finished" + ); + assert!( + after < 50, + "emission stopped well before the full lap count" + ); + + bridge.abort(); + } + + #[tokio::test] + async fn a_newer_running_heat_supersedes_the_previous_one() { + let registry = fast_registry(40, 1); + let (bridge, state) = spawn_bridge_for(®istry); + + // Heat 1 starts running. + state + .append( + Event::HeatScheduled { + heat: HeatId("q-1".into()), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + state + .append( + Event::HeatStateChanged { + heat: HeatId("q-1".into()), + transition: HeatTransition::Running, + }, + None, + ) + .unwrap(); + wait_until(&state, Duration::from_secs(5), |events| { + events + .iter() + .any(|e| matches!(e, Event::Pass(p) if p.competitor == CompetitorRef("A".into()))) + }) + .await; + + // Heat 2 starts running: the bridge must cancel heat 1 and emit for B. + state + .append( + Event::HeatScheduled { + heat: HeatId("q-2".into()), + lineup: vec![CompetitorRef("B".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + state + .append( + Event::HeatStateChanged { + heat: HeatId("q-2".into()), + transition: HeatTransition::Running, + }, + None, + ) + .unwrap(); + wait_until(&state, Duration::from_secs(5), |events| { + events + .iter() + .any(|e| matches!(e, Event::Pass(p) if p.competitor == CompetitorRef("B".into()))) + }) + .await; + + bridge.abort(); + } + + #[tokio::test] + async fn an_event_selecting_only_rotorhazard_emits_nothing() { + // RotorHazard is a reserved no-op stub in this slice (#73): an event whose ONLY selected + // timer is RotorHazard must emit no synthetic passes when its heat runs. + use gridfpv_server::timers::CreateTimerRequest; + let registry = EventRegistry::new(None).unwrap(); + let rh = registry + .timers() + .create(&CreateTimerRequest { + name: "Field RH".into(), + kind: TimerKind::Rotorhazard { + url: "http://rh.local:5000".into(), + }, + channel_capability: None, + node_count: None, + available_channels: None, + }) + .unwrap(); + // Select only the RotorHazard timer for Practice. + registry.set_timers(&practice(), vec![rh.id]).unwrap(); + let (bridge, state) = spawn_bridge_for(®istry); + + let heat = HeatId("q-1".into()); + state + .append( + Event::HeatScheduled { + heat: heat.clone(), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + state + .append( + Event::HeatStateChanged { + heat, + transition: HeatTransition::Running, + }, + None, + ) + .unwrap(); + + // Wait past several poll/lap cycles; no passes should ever land (RH is a stub). + sleep(POLL_INTERVAL * 4).await; + assert_eq!(count_passes(&read_all_events(&state)), 0); + bridge.abort(); + } + + #[tokio::test] + async fn an_event_drives_its_selected_sim_timers_config() { + // An event selecting a CREATED fast Sim timer (not the built-in) runs the synthetic + // emission with THAT timer's laps — proving the bridge reads the per-event selection and + // the selected timer's own config (#73). + use gridfpv_server::timers::CreateTimerRequest; + let registry = EventRegistry::new(None).unwrap(); + let timer = registry + .timers() + .create(&CreateTimerRequest { + name: "Fast Sim".into(), + kind: TimerKind::Mock { laps: 2, lap_ms: 1 }, + channel_capability: None, + node_count: None, + available_channels: None, + }) + .unwrap(); + registry.set_timers(&practice(), vec![timer.id]).unwrap(); + let (bridge, state) = spawn_bridge_for(®istry); + + let heat = HeatId("q-1".into()); + state + .append( + Event::HeatScheduled { + heat: heat.clone(), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + state + .append( + Event::HeatStateChanged { + heat, + transition: HeatTransition::Running, + }, + None, + ) + .unwrap(); + + // 1 pilot, 2 laps + holeshot = 3 passes — the selected timer's laps, not the env default. + wait_until(&state, Duration::from_secs(5), |events| { + count_passes(events) >= 3 + }) + .await; + sleep(POLL_INTERVAL * 3).await; + assert_eq!(count_passes(&read_all_events(&state)), 3); + bridge.abort(); + } + + // --- race redesign Slice 1a: sim auto-presence reconciler --------------------------------- + + /// Spawn the presence reconciler for `registry`'s Practice event, returning its handle and + /// Practice's `AppState` (the same log it polls), mirroring [`spawn_bridge_for`]. + fn spawn_reconciler_for(registry: &EventRegistry) -> (JoinHandle<()>, AppState) { + let state = registry.resolve(&practice()).unwrap(); + let reg = registry.clone(); + let reconciler_state = state.clone(); + let handle = tokio::spawn(async move { + run_presence_reconciler(reconciler_state, reg, practice()).await; + }); + (handle, state) + } + + /// Read the `CompetitorRegistered` bindings currently in the log, as `(competitor, pilot)`. + fn bindings_in(state: &AppState) -> Vec<(String, String)> { + read_all_events(state) + .into_iter() + .filter_map(|e| match e { + Event::CompetitorRegistered { + competitor, pilot, .. + } => Some((competitor.0, pilot.0)), + _ => None, + }) + .collect() + } + + #[tokio::test] + async fn seen_player_matching_a_rostered_pilot_is_added_and_bound() { + use gridfpv_server::pilots::CreatePilotRequest; + + let registry = EventRegistry::new(None).unwrap(); + // A directory pilot whose callsign matches the sim player name (case/space-insensitively). + let pilot = registry + .pilots() + .create(&CreatePilotRequest { + callsign: "AcroAce".into(), + ..Default::default() + }) + .unwrap(); + + let (reconciler, state) = spawn_reconciler_for(®istry); + + // The sim adapter reports a player by name — with surrounding whitespace and different case + // to prove the match is trimmed + case-insensitive. + state + .append( + Event::CompetitorSeen { + adapter: AdapterId(SIM_ADAPTER.into()), + competitor: CompetitorRef(" acroace ".into()), + }, + None, + ) + .unwrap(); + + // The pilot is added to the roster (= present) and a binding is appended. + let pilot_id = pilot.id.clone(); + timeout(Duration::from_secs(5), async { + loop { + let rostered = registry + .meta_of(&practice()) + .map(|m| m.roster.contains(&pilot_id)) + .unwrap_or(false); + if rostered && !bindings_in(&state).is_empty() { + return; + } + sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("the matching pilot should be auto-added and bound"); + + let bindings = bindings_in(&state); + assert_eq!( + bindings, + vec![(" acroace ".to_string(), pilot.id.0.clone())] + ); + + // Idempotent: re-seeing the SAME player (identical adapter+competitor — what the sim + // adapter actually re-emits) adds no second roster entry and no second binding. + state + .append( + Event::CompetitorSeen { + adapter: AdapterId(SIM_ADAPTER.into()), + competitor: CompetitorRef(" acroace ".into()), + }, + None, + ) + .unwrap(); + sleep(POLL_INTERVAL * 3).await; + assert_eq!( + registry.meta_of(&practice()).unwrap().roster, + vec![pilot.id.clone()], + "presence is set-membership — no duplicate roster entry" + ); + assert_eq!( + bindings_in(&state).len(), + 1, + "the already-bound competitor is not re-bound" + ); + + reconciler.abort(); + } + + #[tokio::test] + async fn seen_player_with_no_matching_pilot_is_a_no_op() { + let registry = EventRegistry::new(None).unwrap(); + // No directory pilot named "Stranger". + let (reconciler, state) = spawn_reconciler_for(®istry); + + state + .append( + Event::CompetitorSeen { + adapter: AdapterId(SIM_ADAPTER.into()), + competitor: CompetitorRef("Stranger".into()), + }, + None, + ) + .unwrap(); + + // Wait past several poll cycles: the roster stays empty and no binding is appended. + sleep(POLL_INTERVAL * 4).await; + assert!( + registry.meta_of(&practice()).unwrap().roster.is_empty(), + "an unmatched seen player must not be added to the roster" + ); + assert!( + bindings_in(&state).is_empty(), + "an unmatched seen player must not be bound" + ); + reconciler.abort(); + } + + // --- open practice (open-practice format, Slice 1): laps in memory, not logged ---------------- + + /// Add an **open-practice** round to Practice (open-practice format) over `channels` (node + /// indices) and return its `RoundId`. Uses the registry's `add_round` so the bridge resolves the + /// round through `rounds_of` exactly as it does in production. + fn add_open_practice_round(registry: &EventRegistry, channels: Vec) -> RoundId { + add_open_practice_round_with_limit(registry, channels, None) + } + + /// As [`add_open_practice_round`], but with an optional **time limit** (open-practice refinement) + /// — an open-practice round that has **no win condition** (the form omits it; the inert default is + /// stored) and whose only end condition is the `time_limit_secs` practice duration. + fn add_open_practice_round_with_limit( + registry: &EventRegistry, + channels: Vec, + time_limit_secs: Option, + ) -> RoundId { + use gridfpv_server::events::{NewRoundReq, SeedingRule}; + use gridfpv_server::scope::EventId as ScopeEventId; + let req = NewRoundReq { + label: "Open Practice".into(), + classes: vec![], + format: "open_practice".into(), + params: std::collections::BTreeMap::new(), + // No win condition supplied — an open-practice round does no scoring (the inert default + // is stored by `add_round`). The practice ends on the time limit (or the RD's ForceEnd). + win_condition: None, + time_limit_secs, + seeding: SeedingRule::AllChannels { channels }, + channel_mode: None, + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }; + registry + .add_round(&ScopeEventId(PRACTICE_EVENT_ID.to_string()), req) + .expect("open-practice round added") + .id + } + + /// Schedule an open-practice heat (tagged with `round`, the channel lineup) and drive it to + /// `Running` on Practice's log — exactly what `FillRound` + the control path append. + fn start_open_practice_heat(state: &AppState, round: &RoundId, channels: &[usize]) -> HeatId { + let heat = HeatId("open-practice".into()); + let lineup: Vec = channels + .iter() + .map(|i| CompetitorRef(format!("node-{i}"))) + .collect(); + state + .append( + Event::HeatScheduled { + heat: heat.clone(), + lineup, + class: None, + round: Some(round.clone()), + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + state + .append( + Event::HeatStateChanged { + heat: heat.clone(), + transition: HeatTransition::Running, + }, + None, + ) + .unwrap(); + heat + } + + #[tokio::test] + async fn open_practice_laps_are_in_memory_not_logged_and_drive_live_state() { + // An open-practice heat's passes go to the in-memory per-channel accumulator, NOT the log: + // the log carries the heat's HeatScheduled + start/stop and ZERO Pass events, while the live + // state shows per-channel laps with no pilot bound; the accumulator clears on stop. + let laps = 3u32; + let registry = fast_registry(laps, 1); + let round = add_open_practice_round(®istry, vec![0, 1]); + let (bridge, state) = spawn_bridge_for(®istry); + + let heat = start_open_practice_heat(&state, &round, &[0, 1]); + let op = state.open_practice(); + + // Wait until both channels have accumulated their laps in memory (holeshot + `laps`). + timeout(Duration::from_secs(5), async { + loop { + if let Some(ls) = op.live_state() { + if ls.progress.len() == 2 + && ls.progress.iter().all(|p| p.laps_completed >= laps) + { + return; + } + } + sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("the open-practice accumulator should fill from the sim"); + + // (a) The LOG has the heat's HeatScheduled + start/stop, but ZERO Pass events. + let events = read_all_events(&state); + assert_eq!( + count_passes(&events), + 0, + "an open-practice heat appends NO Pass events to the log" + ); + assert!( + events + .iter() + .any(|e| matches!(e, Event::HeatScheduled { round: Some(r), .. } if *r == round)), + "the heat's HeatScheduled (the session) is logged" + ); + assert!( + events.iter().any(|e| matches!( + e, + Event::HeatStateChanged { + transition: HeatTransition::Running, + .. + } + )), + "the session start is logged" + ); + + // (b) The live state shows per-channel laps, each channel unbound (pilot: None). + let ls = op.live_state().expect("an active open-practice live state"); + assert_eq!(ls.progress.len(), 2); + assert!(ls.progress.iter().all(|p| p.pilot.is_none())); + assert!(ls.progress.iter().all(|p| p.laps_completed >= laps)); + assert_eq!(ls.current_heat, Some(heat.clone())); + + // (c) Clear on stop: a terminal transition drops the accumulator. + state + .append( + Event::HeatStateChanged { + heat: heat.clone(), + transition: HeatTransition::Aborted, + }, + None, + ) + .unwrap(); + timeout(Duration::from_secs(5), async { + loop { + if op.live_state().is_none() { + return; + } + sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("the accumulator should clear on the terminal transition"); + + // Still no Pass events ever reached the log. + assert_eq!(count_passes(&read_all_events(&state)), 0); + bridge.abort(); + } + + #[tokio::test] + async fn open_practice_time_limit_auto_ends_the_running_heat() { + // Open-practice refinement: an open-practice round with **no win condition** but a + // `time_limit_secs` auto-ends its running heat (Running → Unofficial / a `Finished` + // transition) once the elapsed running time reaches the limit — independent of any win + // condition, and even though an open-practice heat logs NO passes (so the win-condition path + // never fires). The completion driver's time-limit branch is the only end condition here. + let registry = fast_registry(3, 1); + // A 1s practice duration (the minimum the seconds field allows): short enough for a test, + // long enough that we can assert it does NOT fire immediately. + let round = add_open_practice_round_with_limit(®istry, vec![0, 1], Some(1)); + let (bridge, state) = spawn_bridge_for(®istry); + + let heat = start_open_practice_heat(&state, &round, &[0, 1]); + + // The heat must still be Running shortly after Start — the limit has not elapsed yet, so no + // premature `Finished` is appended. + sleep(Duration::from_millis(200)).await; + assert_eq!( + gridfpv_engine::heat::heat_state(&read_all_events(&state), &heat), + Some(gridfpv_engine::heat::HeatState::Running), + "the practice must keep running before its time limit elapses" + ); + + // Within a little over the 1s limit, the runtime auto-appends exactly one `Finished` (the + // Running → Unofficial step) with no `ForceEnd` ever sent. + let target = heat.clone(); + timeout( + Duration::from_secs(4), + wait_until(&state, Duration::from_secs(4), move |events| { + gridfpv_engine::heat::heat_state(events, &target) + == Some(gridfpv_engine::heat::HeatState::Unofficial) + }), + ) + .await + .expect("the time limit should auto-end the open-practice heat"); + + let events = read_all_events(&state); + let finished = events + .iter() + .filter(|e| { + matches!( + e, + Event::HeatStateChanged { + heat: h, + transition: HeatTransition::Finished, + } if *h == heat + ) + }) + .count(); + assert_eq!( + finished, 1, + "the time limit auto-appends exactly one Finished (Running → Unofficial)" + ); + // No passes were ever logged for the open-practice heat (the time limit, not scoring, ended it). + assert_eq!(count_passes(&events), 0); + bridge.abort(); + } + + #[tokio::test] + async fn timed_heat_auto_ends_when_no_pass_lands_after_the_window() { + // The timed-window wall-clock fallback: a Timed round's pass-based end criterion + // (`race_end_reached`) needs a lap-gate pass AT/AFTER the cutoff — here every pass lands + // well BEFORE it (the sim finishes its laps in a few ms; the pilots "landed at the + // buzzer"), so without the fallback the heat would stay `Running` forever. The completion + // driver must close it on the wall clock once window + grace elapses. + use gridfpv_engine::scoring::WinCondition; + use gridfpv_server::events::{NewRoundReq, SeedingRule}; + use gridfpv_server::scope::EventId as ScopeEventId; + + let registry = fast_registry(2, 1); // holeshot + 2 laps per pilot, all inside ~10ms + let req = NewRoundReq { + label: "Short Time".into(), + classes: vec![], + format: "timed_qual".into(), + params: std::collections::BTreeMap::new(), + // A 1s window: long enough to assert no premature Finished, short enough for a test. + win_condition: Some(WinCondition::Timed { + window_micros: 1_000_000, + }), + time_limit_secs: None, + seeding: SeedingRule::FromRoster, + channel_mode: None, + staging_timer_secs: None, + start_procedure: None, + // Zero grace so the fallback deadline IS the window end. + grace_window: Some(GraceWindow::Duration { micros: 0 }), + protest_window: None, + min_lap_secs: None, + }; + let round = registry + .add_round(&ScopeEventId(PRACTICE_EVENT_ID.to_string()), req) + .expect("timed round added") + .id; + let (bridge, state) = spawn_bridge_for(®istry); + + let heat = HeatId("q-1".into()); + state + .append( + Event::HeatScheduled { + heat: heat.clone(), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: Some(round), + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + state + .append( + Event::HeatStateChanged { + heat: heat.clone(), + transition: HeatTransition::Running, + }, + None, + ) + .unwrap(); + + // All passes land within a few ms — long before the 1s cutoff — and the heat must still be + // Running mid-window (no premature close). + sleep(Duration::from_millis(300)).await; + assert_eq!( + gridfpv_engine::heat::heat_state(&read_all_events(&state), &heat), + Some(gridfpv_engine::heat::HeatState::Running), + "the timed heat must keep running until its window elapses" + ); + + // Once the window (anchored at the first pass) elapses, the fallback appends Finished even + // though no pass ever landed at/after the cutoff. + let target = heat.clone(); + timeout( + Duration::from_secs(4), + wait_until(&state, Duration::from_secs(4), move |events| { + gridfpv_engine::heat::heat_state(events, &target) + == Some(gridfpv_engine::heat::HeatState::Unofficial) + }), + ) + .await + .expect("the timed window should auto-end the heat with no post-cutoff pass"); + + let events = read_all_events(&state); + let finished = events + .iter() + .filter(|e| { + matches!( + e, + Event::HeatStateChanged { + heat: h, + transition: HeatTransition::Finished, + } if *h == heat + ) + }) + .count(); + assert_eq!(finished, 1, "exactly one auto Finished"); + bridge.abort(); + } + + #[tokio::test] + async fn bridge_startup_replays_nothing_over_a_finished_log() { + // The restart-replay bug: a fresh bridge over a log holding a fully-raced heat used to + // re-fire the historical transitions (a spurious HeatStarting, re-spawned sim sources + // whose passes corrupted the scored window). Startup must append NOTHING for history. + let registry = fast_registry(2, 1); + let state = registry.resolve(&practice()).unwrap(); + let heat = HeatId("q-old".into()); + state + .append( + Event::HeatScheduled { + heat: heat.clone(), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + for transition in [ + HeatTransition::Staged, + HeatTransition::Armed, + HeatTransition::Running, + ] { + state + .append( + Event::HeatStateChanged { + heat: heat.clone(), + transition, + }, + None, + ) + .unwrap(); + } + for (at, seq) in [(1_000_000, 1), (2_000_000, 2)] { + state + .append( + Event::Pass(Pass { + adapter: AdapterId(SIM_ADAPTER.to_string()), + competitor: CompetitorRef("A".into()), + at: SourceTime::from_micros(at), + sequence: Some(seq), + gate: GateIndex::LAP, + signal: None, + heat: Some(heat.clone()), + }), + None, + ) + .unwrap(); + } + for transition in [HeatTransition::Finished, HeatTransition::Finalized] { + state + .append( + Event::HeatStateChanged { + heat: heat.clone(), + transition, + }, + None, + ) + .unwrap(); + } + let before = read_all_events(&state).len(); + + // A fresh bridge over that log (the Director restart): give it a moment to (not) act. + let (bridge, state) = spawn_bridge_for(®istry); + sleep(Duration::from_millis(600)).await; + let after = read_all_events(&state).len(); + assert_eq!( + before, after, + "startup must not replay history (no spurious HeatStarting/Running/passes)" + ); + bridge.abort(); + } + + #[tokio::test] + async fn overlapping_protest_windows_do_not_orphan_the_older_heats_timer() { + // The single-slot detach bug: heat 1 finishes (protest window armed), heat 2 finishes + // + // while heat 1's window is still open — installing heat 2's timer used to DETACH heat + // 1's, so discarding heat 1 could not cancel it and the orphan later force-finalized + // the discarded heat. With per-heat timers + the fire-time recheck, heat 1 must stay + // Scheduled after its discard, no matter what the old timer thought. + let registry = fast_registry(2, 1); + let round = add_protest_window_round(®istry, 700_000); // 0.7s windows + let (bridge, state) = spawn_bridge_for(®istry); + + let schedule = |id: &str| Event::HeatScheduled { + heat: HeatId(id.into()), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: Some(round.clone()), + frequencies: vec![], + label: None, + }; + let changed = |id: &str, t: HeatTransition| Event::HeatStateChanged { + heat: HeatId(id.into()), + transition: t, + }; + // Heat 1 finishes -> its 0.7s auto-official window arms. + state.append(schedule("q-1"), None).unwrap(); + state + .append(changed("q-1", HeatTransition::Finished), None) + .unwrap(); + // Heat 2 finishes inside heat 1's window -> a SECOND live protest timer. + sleep(Duration::from_millis(200)).await; + state.append(schedule("q-2"), None).unwrap(); + state + .append(changed("q-2", HeatTransition::Finished), None) + .unwrap(); + // The RD discards heat 1 while its window is still open. + sleep(Duration::from_millis(100)).await; + state + .append(changed("q-1", HeatTransition::Discarded), None) + .unwrap(); + + // Well past both windows: heat 1 must still be Scheduled (the discard stands); heat 2 + // auto-finalized normally. + let target1 = HeatId("q-1".into()); + let target2 = HeatId("q-2".into()); + timeout( + Duration::from_secs(4), + wait_until(&state, Duration::from_secs(4), move |events| { + gridfpv_engine::heat::heat_state(events, &target2) + == Some(gridfpv_engine::heat::HeatState::Final) + }), + ) + .await + .expect("heat 2's window should auto-finalize it"); + sleep(Duration::from_millis(500)).await; + assert_eq!( + gridfpv_engine::heat::heat_state(&read_all_events(&state), &target1), + Some(gridfpv_engine::heat::HeatState::Scheduled), + "the discarded heat must never be finalized by an orphaned timer" + ); + bridge.abort(); + } + + // --- issue #338: the auto-official driver respects the open-protest gate --------------------- + + /// Add a normal scored round (`timed_qual`) with an **armed protest window** to Practice and + /// return its `RoundId` — the config the auto-official driver reads (`ProtestWindow::After` ⇒ + /// auto-finalize once the window elapses). Uses the registry's `add_round` so the bridge + /// resolves the round through `rounds_of` exactly as in production. + fn add_protest_window_round(registry: &EventRegistry, window_micros: i64) -> RoundId { + use gridfpv_engine::scoring::WinCondition; + use gridfpv_server::events::{NewRoundReq, SeedingRule}; + use gridfpv_server::scope::EventId as ScopeEventId; + let req = NewRoundReq { + label: "Qualifying".into(), + classes: vec![], + format: "timed_qual".into(), + params: std::collections::BTreeMap::new(), + win_condition: Some(WinCondition::Timed { + window_micros: 120_000_000, + }), + time_limit_secs: None, + seeding: SeedingRule::FromRoster, + channel_mode: None, + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: Some(ProtestWindow::After { + micros: window_micros, + }), + min_lap_secs: None, + }; + registry + .add_round(&ScopeEventId(PRACTICE_EVENT_ID.to_string()), req) + .expect("protest-window round added") + .id + } + + /// Schedule a heat tagged with `round` and end its race directly (`Finished` lands it in + /// `Unofficial`) — the transition the bridge observes to arm the auto-official driver. + fn finish_round_heat(state: &AppState, round: &RoundId) -> HeatId { + let heat = HeatId("q-1".into()); + state + .append( + Event::HeatScheduled { + heat: heat.clone(), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: Some(round.clone()), + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + state + .append( + Event::HeatStateChanged { + heat: heat.clone(), + transition: HeatTransition::Finished, + }, + None, + ) + .unwrap(); + heat + } + + /// Whether the log carries the auto (or manual) `Finalized` transition for `heat`. + fn finalized_in(events: &[Event], heat: &HeatId) -> bool { + events.iter().any(|e| { + matches!( + e, + Event::HeatStateChanged { + heat: h, + transition: HeatTransition::Finalized, + } if h == heat + ) + }) + } + + #[tokio::test] + async fn auto_official_finalizes_after_the_window_with_no_protests() { + // The happy path (marshaling Slice 5): a round with a protest window auto-finalizes its + // Unofficial heat once the window elapses — the driver logs the `HeatFinalizing` deadline, + // holds the window, and appends the `Finalized` transition, with no protest on file. + let registry = fast_registry(3, 1); + let round = add_protest_window_round(®istry, 200_000); // a 0.2 s window + let (bridge, state) = spawn_bridge_for(®istry); + + let heat = finish_round_heat(&state, &round); + + let target = heat.clone(); + timeout( + Duration::from_secs(5), + wait_until(&state, Duration::from_secs(5), move |events| { + finalized_in(events, &target) + }), + ) + .await + .expect("the auto-official driver should finalize once the window elapses"); + + let events = read_all_events(&state); + assert!( + events + .iter() + .any(|e| matches!(e, Event::HeatFinalizing { heat: h, .. } if *h == heat)), + "the driver logs the deadline fact before the hold" + ); + assert_eq!( + gridfpv_engine::heat::heat_state(&events, &heat), + Some(gridfpv_engine::heat::HeatState::Final), + "the heat folds to Final after the auto-official append" + ); + bridge.abort(); + } + + #[tokio::test] + async fn auto_official_stands_down_on_an_open_protest_until_resolved() { + // Issue #338: the protest window expiring must NOT finalize over an OPEN protest — the + // same gate the manual `Finalize` command enforces (P1-4). The driver stands down and + // leaves the heat Unofficial; resolving the protest then lets the RD's manual `Finalize` + // (the chosen behavior — no auto-retry) close the heat. + use gridfpv_events::{LogRef, ProtestOutcome}; + use gridfpv_server::control::Command; + use gridfpv_server::control_handler::apply_command; + + let registry = fast_registry(3, 1); + let round = add_protest_window_round(®istry, 200_000); // a 0.2 s window + let (bridge, state) = spawn_bridge_for(®istry); + + // File the protest BEFORE the race ends, so it is open for the whole window — no timing + // race between the filing and the driver's expiry check. + let heat = HeatId("q-1".into()); + state + .append( + Event::HeatScheduled { + heat: heat.clone(), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: Some(round.clone()), + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + let filed = state + .append( + Event::ProtestFiled { + heat: heat.clone(), + competitor: CompetitorRef("A".into()), + note: "contested line cut".into(), + }, + None, + ) + .unwrap(); + state + .append( + Event::HeatStateChanged { + heat: heat.clone(), + transition: HeatTransition::Finished, + }, + None, + ) + .unwrap(); + + // The driver still arms (it logs the deadline — the console countdown runs as usual)... + let target = heat.clone(); + timeout( + Duration::from_secs(5), + wait_until(&state, Duration::from_secs(5), move |events| { + events + .iter() + .any(|e| matches!(e, Event::HeatFinalizing { heat: h, .. } if *h == target)) + }), + ) + .await + .expect("the driver logs the deadline even with a protest on file"); + + // ...but well past the window (bridge poll + 0.2 s hold + slack) it has appended NO + // `Finalized`: the heat stays Unofficial for the RD. + sleep(Duration::from_millis(800)).await; + let events = read_all_events(&state); + assert!( + !finalized_in(&events, &heat), + "the expired window must not finalize over an open protest" + ); + assert_eq!( + gridfpv_engine::heat::heat_state(&events, &heat), + Some(gridfpv_engine::heat::HeatState::Unofficial), + "the heat is left Unofficial for the RD" + ); + + // The manual path agrees while the protest is open (the shared predicate)... + let ack = apply_command(&state, Command::Finalize { heat: heat.clone() }); + assert!( + !ack.ok, + "manual Finalize is blocked by the same open-protest gate" + ); + + // ...and resolving the protest unblocks the RD's manual Finalize (the chosen behavior: + // once a protest pulled a human into the loop, closing the heat is the RD's click). + state + .append( + Event::ProtestResolved { + target: LogRef(filed), + outcome: ProtestOutcome::Denied, + }, + None, + ) + .unwrap(); + let ack = apply_command(&state, Command::Finalize { heat: heat.clone() }); + assert!(ack.ok, "Finalize succeeds once the protest is resolved"); + assert_eq!( + gridfpv_engine::heat::heat_state(&read_all_events(&state), &heat), + Some(gridfpv_engine::heat::HeatState::Final), + "the resolved-then-finalized heat folds to Final" + ); + bridge.abort(); + } + + #[test] + fn source_config_defaults_to_sim_and_describes_itself() { + // No env reliance: build a sim config directly and confirm the banner text. + let cfg = SourceConfig::Sim(SimSource::new(5, Duration::from_millis(2500))); + let desc = cfg.describe(); + assert!(desc.contains("sim")); + assert!(desc.contains('5')); + } + + #[test] + fn per_pilot_pace_spreads_the_field() { + let sim = SimSource::new(5, Duration::from_millis(2000)); + // Seed 0 is the nominal pace; later seeds are slower (so the order isn't a tie). + assert!(sim.pilot_lap(1) > sim.pilot_lap(0)); + assert!(sim.pilot_lap(2) > sim.pilot_lap(1)); + } + + // --- issue #112: primary/alternate roles + single-active-source feed + failover ------------- + + /// Create a second fast **Mock** timer in `registry` and return its id (a redundant timer for + /// the failover/double-count tests). + fn create_mock(registry: &EventRegistry, name: &str, laps: u32, lap_ms: u64) -> TimerId { + registry + .timers() + .create(&CreateTimerRequest { + name: name.into(), + kind: TimerKind::Mock { laps, lap_ms }, + channel_capability: None, + node_count: None, + available_channels: None, + }) + .unwrap() + .id + } + + /// Drive a Running heat for `lineup` on Practice's log and return its `HeatId`. + fn start_heat(state: &AppState, lineup: Vec) -> HeatId { + let heat = HeatId("q-1".into()); + state + .append( + Event::HeatScheduled { + heat: heat.clone(), + lineup, + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + state + .append( + Event::HeatStateChanged { + heat: heat.clone(), + transition: HeatTransition::Running, + }, + None, + ) + .unwrap(); + heat + } + + #[tokio::test] + async fn two_healthy_timers_feed_only_the_primary_no_double_count() { + // The double-count fix (issue #112): two redundant Mock timers at the same gate are BOTH + // healthy, but only the **primary** feeds the log — so the same crossing is counted once, + // not twice. Before #112 every selected timer fed, doubling every pass. + let laps = 3u32; + let registry = fast_registry(laps, 1); + let primary = TimerId(MOCK_TIMER_ID.to_string()); + let alternate = create_mock(®istry, "Backup", laps, 1); + // Select both; the first (the built-in Mock) is the default primary. + registry + .set_timers(&practice(), vec![primary.clone(), alternate]) + .unwrap(); + let (bridge, state) = spawn_bridge_for(®istry); + + start_heat(&state, vec![CompetitorRef("A".into())]); + + // One pilot, holeshot + `laps` = laps+1 passes — for ONE timer, even though two are healthy. + let expected = laps as usize + 1; + timeout( + Duration::from_secs(5), + wait_until(&state, Duration::from_secs(5), move |events| { + count_passes(events) >= expected + }), + ) + .await + .expect("the primary should emit all its passes"); + // Settle past several poll/lap cycles and assert the count never exceeded one timer's worth. + sleep(POLL_INTERVAL * 4).await; + assert_eq!( + count_passes(&read_all_events(&state)), + expected, + "only the primary feeds — two healthy timers must NOT double-count" + ); + bridge.abort(); + } + + #[tokio::test] + async fn fails_over_to_alternate_when_the_primary_drops_mid_heat() { + // Primary = an RH timer (its health is the Director-driven connection status, toggled here), + // alternate = a fast Mock. While the RH primary is `Connected` it is the active source — but + // a non-`live` build has no RH connection feeding, so NO passes land (the Mock alternate is + // gated off, hot standby). Dropping the RH primary fails over to the Mock alternate, whose + // synthetic passes then take over — exactly the "primary RH drops → Mock alternate takes + // over" scenario, proven in-process without Docker. + let registry = EventRegistry::new(None).unwrap(); + let rh = registry + .timers() + .create(&CreateTimerRequest { + name: "Field RH".into(), + kind: TimerKind::Rotorhazard { + url: "http://rh.local:5000".into(), + }, + channel_capability: None, + node_count: None, + available_channels: None, + }) + .unwrap() + .id; + // A long, slow Mock so it is still mid-emission (passes left to feed) when the primary drops + // — a hot-standby alternate runs its emission in real time, so a failover only catches the + // passes that have yet to be emitted. + let mock = create_mock(®istry, "Backup Mock", 200, 30); + registry + .set_timers(&practice(), vec![rh.clone(), mock.clone()]) + .unwrap(); + registry + .set_primary_timer(&practice(), Some(rh.clone())) + .unwrap(); + // Bring the RH primary "up" — it is the active source, so the Mock alternate is gated off. + registry.timers().set_status(&rh, TimerStatus::Connected); + + let (bridge, state) = spawn_bridge_for(®istry); + start_heat(&state, vec![CompetitorRef("A".into())]); + + // While the RH primary is healthy, no passes land (no in-process RH feed; Mock is standby). + sleep(POLL_INTERVAL * 2).await; + assert_eq!( + count_passes(&read_all_events(&state)), + 0, + "the healthy RH primary is the active source; the Mock alternate must stay gated off" + ); + + // Drop the RH primary: the bridge re-evaluates each poll and fails over to the Mock + // alternate, whose passes now take over. + registry.timers().set_status(&rh, TimerStatus::Disconnected); + timeout( + Duration::from_secs(5), + wait_until(&state, Duration::from_secs(5), |events| { + count_passes(events) >= 2 + }), + ) + .await + .expect("the Mock alternate should take over once the RH primary drops"); + + // Recovery (primary-preferred): bring the RH back and assert the Mock stops feeding. + registry.timers().set_status(&rh, TimerStatus::Connected); + sleep(POLL_INTERVAL * 2).await; + let settled = count_passes(&read_all_events(&state)); + sleep(POLL_INTERVAL * 4).await; + assert_eq!( + count_passes(&read_all_events(&state)), + settled, + "on primary recovery the active source switches back; the Mock alternate stops feeding" + ); + bridge.abort(); + } +} diff --git a/crates/app/src/source/failover.rs b/crates/app/src/source/failover.rs new file mode 100644 index 0000000..e5a2c3d --- /dev/null +++ b/crates/app/src/source/failover.rs @@ -0,0 +1,178 @@ +//! **Primary/alternate timer roles + failover** — the single-active-source selection (issue #112). +//! +//! Two timers at the **same gate** give redundancy: among an event's selected +//! [`timers`](gridfpv_server::events::EventMeta::timers) one is the **primary**, the rest are +//! **alternates**. All selected timers stay *connected* (hot standby — the persistent RH connection +//! from #105 connects every selected RH timer), but the per-event source bridge feeds **only the +//! active source's** passes into the event log, so the same physical crossing is never +//! double-counted when more than one timer is selected. +//! +//! # The selection rule (primary-preferred) +//! +//! At any instant exactly one selected timer is the **active source**: +//! +//! - the **primary** (its [`EventMeta::effective_primary`](gridfpv_server::events::EventMeta::effective_primary)) +//! while it is **healthy**, else +//! - the **first healthy alternate** in selection order, else +//! - `None` (no selected timer is healthy — nothing feeds). +//! +//! When the primary recovers it is preferred again (a failover is not sticky), so a primary that +//! drops mid-race and reconnects takes the feed back from the alternate. +//! +//! A timer is **healthy** when it can currently produce passes: a **Mock** is always healthy (it +//! needs nothing external), and a **RotorHazard** timer is healthy only while its persistent +//! connection reports [`TimerStatus::Connected`]. `Connecting`/`Disconnected`/`Error`/`Configured` +//! are all *not healthy* — a timer mid-connect or dropped must not be the active source. +//! +//! This module is pure (no I/O): it maps a selection + the live timer statuses to the one active +//! [`TimerId`], so it is unit-testable without spawning a bridge, and is shared by the bridge (which +//! gates each source's appends on being the active one) and the failover tests. + +use gridfpv_server::events::EventMeta; +use gridfpv_server::timers::{TimerId, TimerKind, TimerRegistry, TimerStatus}; + +/// Whether a timer is **healthy** — able to feed passes right now (issue #112). +/// +/// A Mock is always healthy. A RotorHazard timer is healthy only while its persistent connection +/// is [`TimerStatus::Connected`]; any other status (connecting, dropped, errored, resting at +/// `Configured`) is not healthy, so it cannot be the active source. An id that no longer resolves +/// (a since-deleted timer) is treated as not healthy. +pub fn timer_is_healthy(timers: &TimerRegistry, id: &TimerId) -> bool { + let Some(timer) = timers.get(id) else { + return false; + }; + match timer.kind { + // The synthetic source needs nothing external — always ready to feed. + TimerKind::Mock { .. } => true, + // A live RH timer feeds only while its persistent connection is up. + TimerKind::Rotorhazard { .. } => timer.status == TimerStatus::Connected, + } +} + +/// The **active source** for an event right now (issue #112): the primary while healthy, else the +/// first healthy alternate in selection order, else `None`. +/// +/// `meta` carries the selection and the effective primary; `timers` supplies each timer's live +/// status (driven by the #105 persistent-connection reconciler for RH timers). This is the single +/// rule the bridge applies every poll to decide whose passes feed the log — so a primary drop fails +/// over to an alternate and a primary recovery switches back, all without double-counting. +pub fn active_source(meta: &EventMeta, timers: &TimerRegistry) -> Option { + // Prefer the primary while it is healthy (failover is not sticky — the primary wins back). + if let Some(primary) = meta.effective_primary() { + if timer_is_healthy(timers, &primary) { + return Some(primary); + } + } + // Otherwise the first healthy alternate, scanning the selection in order. + meta.timers + .iter() + .find(|id| timer_is_healthy(timers, id)) + .cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + use gridfpv_server::events::EventRegistry; + use gridfpv_server::scope::EventId; + use gridfpv_server::timers::{CreateTimerRequest, MOCK_TIMER_ID}; + + /// Build a registry plus one Mock and one RH timer, select both for Practice (primary = the + /// first, by default), and return the pieces the selection logic needs. + fn setup() -> (EventRegistry, EventId, TimerId, TimerId) { + let registry = EventRegistry::new(None).unwrap(); + let mock = TimerId(MOCK_TIMER_ID.to_string()); + let rh = registry + .timers() + .create(&CreateTimerRequest { + name: "Field RH".into(), + kind: TimerKind::Rotorhazard { + url: "http://rh.local:5000".into(), + }, + channel_capability: None, + node_count: None, + available_channels: None, + }) + .unwrap(); + let practice = EventId("practice".into()); + (registry, practice, mock, rh.id) + } + + #[test] + fn single_mock_is_always_the_active_source() { + let (registry, practice, mock, _rh) = setup(); + registry.set_timers(&practice, vec![mock.clone()]).unwrap(); + let meta = registry.meta_of(&practice).unwrap(); + // One timer = primary = the only source, exactly as before #112. + assert_eq!(active_source(&meta, ®istry.timers()), Some(mock)); + } + + #[test] + fn primary_is_active_when_healthy_even_with_a_healthy_alternate() { + // Primary Mock + alternate Mock: the primary feeds, the alternate is hot standby (this is + // the double-count fix — only one of two healthy timers is ever the active source). + let (registry, practice, mock, rh) = setup(); + // Connect the RH alternate so BOTH are healthy. + registry.timers().set_status(&rh, TimerStatus::Connected); + // Selection [mock, rh] with the Mock primary (the default first). + registry + .set_timers(&practice, vec![mock.clone(), rh.clone()]) + .unwrap(); + let meta = registry.meta_of(&practice).unwrap(); + assert_eq!(active_source(&meta, ®istry.timers()), Some(mock)); + } + + #[test] + fn fails_over_to_alternate_when_primary_drops_then_back_on_recovery() { + // Primary = RH, alternate = Mock. Healthy RH primary feeds; RH drops → Mock alternate + // feeds; RH recovers → primary feeds again (primary-preferred, not sticky). + let (registry, practice, mock, rh) = setup(); + registry + .set_timers(&practice, vec![rh.clone(), mock.clone()]) + .unwrap(); + registry + .set_primary_timer(&practice, Some(rh.clone())) + .unwrap(); + let timers = registry.timers(); + + // RH connected → primary RH is the active source. + timers.set_status(&rh, TimerStatus::Connected); + let meta = registry.meta_of(&practice).unwrap(); + assert_eq!(active_source(&meta, &timers), Some(rh.clone())); + + // RH drops → fail over to the Mock alternate. + timers.set_status(&rh, TimerStatus::Disconnected); + assert_eq!(active_source(&meta, &timers), Some(mock.clone())); + + // RH recovers → switch back to the primary. + timers.set_status(&rh, TimerStatus::Connected); + assert_eq!(active_source(&meta, &timers), Some(rh)); + } + + #[test] + fn no_healthy_timer_yields_no_active_source() { + // Only a disconnected RH selected → nothing healthy → nothing feeds. + let (registry, practice, _mock, rh) = setup(); + registry.set_timers(&practice, vec![rh.clone()]).unwrap(); + registry.timers().set_status(&rh, TimerStatus::Disconnected); + let meta = registry.meta_of(&practice).unwrap(); + assert_eq!(active_source(&meta, ®istry.timers()), None); + } + + #[test] + fn a_stale_primary_falls_back_to_the_first_selected() { + // A primary pointing at a timer no longer selected degrades to the first selected timer. + let (registry, practice, mock, rh) = setup(); + registry + .set_timers(&practice, vec![mock.clone(), rh.clone()]) + .unwrap(); + registry + .set_primary_timer(&practice, Some(rh.clone())) + .unwrap(); + // Deselect the RH (the primary) — set_timers clears the stale primary. + registry.set_timers(&practice, vec![mock.clone()]).unwrap(); + let meta = registry.meta_of(&practice).unwrap(); + assert_eq!(meta.effective_primary(), Some(mock.clone())); + assert_eq!(active_source(&meta, ®istry.timers()), Some(mock)); + } +} diff --git a/crates/app/src/source/rh_connections.rs b/crates/app/src/source/rh_connections.rs new file mode 100644 index 0000000..e215e2c --- /dev/null +++ b/crates/app/src/source/rh_connections.rs @@ -0,0 +1,215 @@ +//! The **active-event RotorHazard connection set** + its reconciler (#105) — `live`-gated. +//! +//! A RotorHazard timer **connects when it is selected for the active event and stays connected** +//! (#105), so its live link is monitored continuously and a drop-off surfaces *before and between* +//! races. This module owns the set of persistent [`RhConnection`]s and the background task that +//! keeps that set in sync with the Director's state: +//! +//! - [`RhConnections`] — a shared `(EventId, TimerId) → RhConnection` map. The per-event source +//! bridge consults it to **arm/disarm** a running heat onto the *already-live* connection (race +//! driving decoupled from connecting); the reconciler opens/closes entries. +//! - [`spawn_rh_reconciler`] — polls the registry's **active event** and **its selected timers**, +//! and reconciles: open a connection for every selected RotorHazard timer of the active event, +//! close any connection whose timer was deselected or whose event is no longer active. On the +//! active event changing, the previous event's connections are dropped (left `Disconnected`) and +//! the new event's selected RH timers connect. +//! +//! Only the **active** event's RH timers hold a live socket — an idle, non-active event does not +//! tie up the timer. (A non-active event's heat can still be driven through its bridge, but it just +//! finds no live connection to arm; RH racing is for the event the Director is running.) + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use gridfpv_events::CompetitorRef; +use gridfpv_server::events::EventRegistry; +use gridfpv_server::scope::EventId; +use gridfpv_server::timers::{TimerId, TimerKind, TimerRegistry}; +use tokio::task::JoinHandle; + +use super::PassSink; +use super::rotorhazard::RhConnection; + +/// How often the reconciler polls the active event + its selected timers to sync the live set. +pub const RECONCILE_INTERVAL: Duration = Duration::from_millis(500); + +/// The shared set of persistent RotorHazard connections, keyed by *(active event, RH timer)* (#105). +/// +/// Cloning shares the one map (`Arc>`), so the reconciler (which opens/closes connections) +/// and the per-event source bridges (which arm/disarm a running heat onto a live connection) act on +/// the same connections. A connection exists only while its timer is selected for the active event. +#[derive(Clone, Default)] +pub struct RhConnections { + inner: Arc>>, +} + +impl RhConnections { + /// An empty connection set. + pub fn new() -> Self { + Self::default() + } + + /// Arm a running heat onto the live connection for `(event, timer)`, if one exists: the driver + /// stages the RH race and routes its passes (remapped onto `lineup`) into `sink`'s log. Returns + /// whether a live connection was found to arm (a non-active event, or a timer not yet connected, + /// finds none — the heat then drives no RH passes, which is correct). + pub fn arm_heat( + &self, + event: &EventId, + timer: &TimerId, + lineup: Vec, + sink: PassSink, + ) -> bool { + let map = self.inner.lock().expect("rh-connections lock poisoned"); + if let Some(conn) = map.get(&(event.clone(), timer.clone())) { + conn.arm_heat(lineup, sink); + true + } else { + false + } + } + + /// **Tune** `(event, timer)`'s connection to a per-node channel assignment (race redesign Slice + /// 4a), if a live connection exists: the driver emits a `set_frequency` per node so the device + /// tunes to the staging heat's assigned channels ("the engine allocates, the adapter applies"). + /// Returns whether a live connection was found to tune. A no-op (returns `false`) for a + /// non-active event or a not-yet-connected timer. + pub fn tune(&self, event: &EventId, timer: &TimerId, assignment: Vec<(u64, u16)>) -> bool { + let map = self.inner.lock().expect("rh-connections lock poisoned"); + if let Some(conn) = map.get(&(event.clone(), timer.clone())) { + conn.tune(assignment); + true + } else { + false + } + } + + /// **Prepare** `(event, timer)`'s connection for an instant start (Grid owns all timing), if a + /// live connection exists: the driver zeroes RH's current-format staging (no RH-side hold/tones) + /// and resets RH to READY, so the eventual arm at Grid's go starts RH recording immediately. The + /// bridge calls this when a heat is **Staged** — before the Armed hold + tone — so all the + /// reset/format work happens ahead of go. Returns whether a live connection was found to prepare; + /// a no-op (`false`) for a non-active event or a not-yet-connected timer. + pub fn prepare(&self, event: &EventId, timer: &TimerId) -> bool { + let map = self.inner.lock().expect("rh-connections lock poisoned"); + if let Some(conn) = map.get(&(event.clone(), timer.clone())) { + conn.prepare(); + true + } else { + false + } + } + + /// **Seat** `(event, timer)`'s connection with the heat's `(node_index, callsign)` bind (the + /// laps-attribute fix), if a live connection exists: the driver builds a fresh RH heat with those + /// pilots seated onto their nodes and makes it current, so RH **records and attributes** passes on + /// the bound nodes (RH dismisses a crossing on a node with no seated pilot). The bridge calls this + /// when a heat is **Staged**, alongside `prepare`/`tune`. Returns whether a live connection was + /// found to seat; a no-op (`false`) for a non-active event or a not-yet-connected timer. + pub fn seat(&self, event: &EventId, timer: &TimerId, seats: Vec<(u64, String)>) -> bool { + let map = self.inner.lock().expect("rh-connections lock poisoned"); + if let Some(conn) = map.get(&(event.clone(), timer.clone())) { + conn.seat(seats); + true + } else { + false + } + } + + /// Disarm the current heat on `(event, timer)`'s connection (the heat left `Running`): the race + /// is stopped/cleared but the **connection stays alive**. A no-op if no such connection. + pub fn disarm(&self, event: &EventId, timer: &TimerId) { + let map = self.inner.lock().expect("rh-connections lock poisoned"); + if let Some(conn) = map.get(&(event.clone(), timer.clone())) { + conn.disarm(); + } + } + + /// Reconcile the live set against `wanted` (the active event's selected RH timers, each with its + /// url): open a connection for any wanted pair not yet live, and cancel+remove every live + /// connection no longer wanted (a deselected timer, or the active event having changed). The + /// `timers` registry is where each opened connection publishes its status. + fn reconcile(&self, wanted: &[(EventId, TimerId, String)], timers: &TimerRegistry) { + let mut map = self.inner.lock().expect("rh-connections lock poisoned"); + + // Close connections no longer wanted (deselected timer / active event changed / no active). + let keep: std::collections::HashSet<(EventId, TimerId)> = wanted + .iter() + .map(|(e, t, _)| (e.clone(), t.clone())) + .collect(); + let stale: Vec<(EventId, TimerId)> = map + .keys() + .filter(|key| !keep.contains(*key)) + .cloned() + .collect(); + // Timer ids that stay wanted (under ANY event) — their stale connection is being + // REPLACED, so its exiting driver must yield the shared status to the successor. + let wanted_timers: std::collections::HashSet<&TimerId> = + wanted.iter().map(|(_, t, _)| t).collect(); + for key in stale { + if let Some(conn) = map.remove(&key) { + if wanted_timers.contains(&key.1) { + // Tear down, yielding the status cell to the successor connection. + conn.cancel_superseded(); + } else { + // Tear down on the driver thread (stop race + disconnect + leave Disconnected). + conn.cancel(); + } + } + } + + // Open connections for newly-wanted pairs (lazily — an already-live pair is left as is). + for (event, timer, url) in wanted { + let key = (event.clone(), timer.clone()); + map.entry(key) + .or_insert_with(|| RhConnection::open(timer.clone(), url.clone(), timers.clone())); + } + } +} + +/// The set of RotorHazard timers `registry`'s **active event** currently selects, each paired with +/// the active event id and its url. The reconciler's source of truth (#105): when the active event +/// changes or its selection changes, this set changes and the live connections follow. +fn wanted_connections( + registry: &EventRegistry, + timers: &TimerRegistry, +) -> Vec<(EventId, TimerId, String)> { + let Some(active) = registry.active() else { + return Vec::new(); + }; + let Some(selection) = registry.timers_of(&active.id) else { + return Vec::new(); + }; + let mut wanted = Vec::new(); + for id in selection { + if let Some(timer) = timers.get(&id) { + if let TimerKind::Rotorhazard { url } = timer.kind { + wanted.push((active.id.clone(), id, url)); + } + } + } + wanted +} + +/// Spawn the reconciler (#105): poll the active event + its selected RH timers on +/// [`RECONCILE_INTERVAL`] and keep `connections` in sync — opening a persistent connection for each +/// selected RotorHazard timer of the active event and closing those no longer selected (or whose +/// event is no longer active). Returns the reconciler's [`JoinHandle`]; it runs for the process +/// lifetime. The returned [`RhConnections`] is the shared set the per-event bridges arm heats on. +pub fn spawn_rh_reconciler(registry: EventRegistry) -> (RhConnections, JoinHandle<()>) { + let connections = RhConnections::new(); + let timers = registry.timers(); + let handle = { + let connections = connections.clone(); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(RECONCILE_INTERVAL); + loop { + ticker.tick().await; + let wanted = wanted_connections(®istry, &timers); + connections.reconcile(&wanted, &timers); + } + }) + }; + (connections, handle) +} diff --git a/crates/app/src/source/rotorhazard.rs b/crates/app/src/source/rotorhazard.rs new file mode 100644 index 0000000..69090f0 --- /dev/null +++ b/crates/app/src/source/rotorhazard.rs @@ -0,0 +1,1034 @@ +//! The live **RotorHazard connection layer** (#65, #105) — only compiled under `--features live`. +//! +//! A RotorHazard timer **connects when it is selected for the active event and stays connected**, +//! so the Director monitors its live link continuously and a drop-off is visible *before and +//! between* races — not only while a heat runs (#105). The earlier slice (#65) connected an RH +//! timer only for the duration of a `Running` heat and tore the socket down when the heat left +//! `Running`; that meant a selected-but-idle timer read `Configured` and a drop could not surface +//! until a race was underway. +//! +//! This module splits that responsibility into two pieces: +//! +//! 1. **The persistent connection** ([`RhConnection`]). One per *(active event, RH timer)* pair. +//! A dedicated [`spawn_blocking`](tokio::task::spawn_blocking) driver thread connects to the RH +//! server, drives the timer's [`TimerStatus`](gridfpv_server::timers::TimerStatus) through its +//! lifecycle (`Connecting → Connected`), then **loops maintaining and monitoring** the link: +//! on a drop it sets `Disconnected`/`Error` and **reconnects with backoff**, updating the status +//! across attempts. While connected it continuously drains the translated event stream — when a +//! heat is "armed" on the connection (see below) it appends the translated passes (remapped onto +//! the heat lineup) into the event log; otherwise it discards them (idle monitoring). On cancel +//! (the timer is deselected, the active event changes, or the Director shuts down) it stops any +//! running race, disconnects, and leaves the timer [`Disconnected`]. +//! +//! 2. **Race driving, decoupled from the connection.** A running heat does **not** open a socket +//! of its own — it *uses the already-live connection*. The lifecycle splits across two bridge +//! hooks so that **GridFPV owns all start/stop timing and RH is only a start/stop/get-data +//! device** (no RH-side staging countdown or tone competing with Grid's start procedure): +//! * at **Stage** the bridge [prepares](RhConnection::prepare) each selected RH connection — +//! zero RH's current-format staging (no staging hold/tones) and reset RH to READY, well ahead +//! of Grid's go; +//! * at **Running** (the `Armed → Running` instant, when Grid's tone fires) the bridge +//! [arms](RhConnection::arm_heat) the heat — the driver emits a single `stage_race` so RH +//! begins recording **immediately** (no reset, no settle, no RH staging) and remaps its node +//! seats onto the heat lineup; the driver thread then routes drained passes into the event log. +//! +//! When the heat leaves `Running` the bridge [disarms](RhConnection::disarm) it — the race is +//! stopped/cleared but the **connection stays alive** (and keeps reporting status). +//! +//! # Why a dedicated driver thread +//! +//! The RotorHazard transport's emit/poll are **blocking** — they `block_on` an internal runtime — +//! so they must never run on a tokio worker thread (that panics). The entire connection lifecycle +//! (connect → monitor → reconnect → stage → drain → stop → disconnect) therefore runs on one +//! dedicated `spawn_blocking` thread; the async side only holds a handle that flips shared atomics +//! (a cancel flag, and an "armed heat" slot). The driver checks those each loop and reacts on its +//! own thread, so nothing ever emits on a tokio worker. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use gridfpv_adapters::rotorhazard::RotorHazardAdapter; +use gridfpv_adapters::rotorhazard::transport::{ + DIRECTOR_PROTOCOL_VERSION, PluginHello, RotorHazardConnection, +}; +use gridfpv_events::{AdapterId, CompetitorRef, Event}; +use gridfpv_server::timers::{PluginPresence, TimerId, TimerRegistry, TimerStatus}; +use tokio::task::JoinHandle; + +use super::PassSink; + +/// How often the driver thread drains the RotorHazard connection's translated-event queue. +const DRAIN_INTERVAL: Duration = Duration::from_millis(100); + +/// How long to wait, after staging an armed heat, for the RH race to reach RACING before giving up +/// on the wait (the drain loop still runs regardless — this only bounds the staging settle). +const STAGE_SETTLE: Duration = Duration::from_secs(15); + +/// How often to RE-EMIT `stage_race` while staged-but-not-RACING inside the settle window — a +/// busy RH silently drops a stage ("status is not 'ready'"); without the retry the race never +/// starts on the RH side and no passes are ever recorded. Paced ABOVE RH's stock staging +/// sequence (~3-4s with unzeroed delays) so a retry can never land mid-staging and restart the +/// countdown it is waiting on; with the instant-start prepare applied, staging is sub-second +/// and the retry only ever fires on a genuinely dropped stage. +const STAGE_RETRY_INTERVAL: Duration = Duration::from_secs(5); + +/// How long the driver keeps routing drained events into a **finishing** heat's sink after it +/// stopped the RH race (marshaling path-2). Stopping the race drives RH to `DONE`, which +/// auto-triggers the dense `current_marshal_data` / `save_laps` → `race_list` → `get_pilotrace` +/// marshal pull; those round-trips take a moment on RH's gevent loop, so we keep the heat's sink +/// armed this long to capture the resulting [`Event::SignalHistory`] into the right heat's log +/// before clearing the slot. Generous enough for the per-pilotrace pull chain, still brief. +const FINISH_DRAIN_SETTLE: Duration = Duration::from_secs(3); + +/// How long the driver waits, at heat-end, for RotorHazard's `heat_data` response (after +/// `ensure_savable_heat`'s `add_heat`/`load_data`) before giving up on selecting a savable heat and +/// stopping the race anyway. Bounds the case where an older/quirky RH never answers `heat_data` — +/// the finish proceeds and the dense pull simply no-ops (the coarse trace still stands). +const ENSURE_HEAT_TIMEOUT: Duration = Duration::from_secs(3); + +/// The minimum backoff between reconnect attempts after a dropped/failed connection. +const RECONNECT_BACKOFF_MIN: Duration = Duration::from_millis(500); + +/// The maximum backoff between reconnect attempts (the backoff doubles up to this ceiling). +const RECONNECT_BACKOFF_MAX: Duration = Duration::from_secs(10); + +/// How long the connection can drain no events before it probes liveness with a fresh `load_data`. +/// RH pushes asynchronously, so a healthy idle link is silent; the probe distinguishes "idle" from +/// "dropped" without depending on transport-level disconnect callbacks. +const IDLE_PROBE_INTERVAL: Duration = Duration::from_secs(5); + +/// How long to wait, after connecting, for the GridFPV plugin's `gridfpv_hello_ack` (D16, S1). A +/// plugin-equipped RH replies near-instantly (`wait_for_plugin` returns as soon as it lands); a +/// stock RH never answers, so this bounds how long we wait before declaring the plugin *missing* +/// and entering the maintain loop. Kept short — the only cost is a one-time per-connect delay +/// against a stock RH, which then gets the guided-install prompt anyway. +const PLUGIN_PROBE_TIMEOUT: Duration = Duration::from_secs(3); + +/// A **pending tune** the driver applies on its next loop (race redesign Slice 4a): the per-node +/// `(node_index, frequency_mhz)` assignment the engine allocated for the staging heat, shared from +/// the async [`RhConnection::tune`] caller to the blocking driver thread. `None` ⇒ nothing pending. +type TuneSlot = Arc>>>; + +/// A **pending prepare** the driver applies on its next loop: when a heat is **Staged** the bridge +/// asks the connection to ready RH for an instant start — zero the current format's staging delays +/// (so `stage_race` has no RH-side hold/tones) and reset RH to a clean READY state. Shared from the +/// async [`RhConnection::prepare`] caller to the blocking driver thread; `true` ⇒ a prepare is due. +type PrepareSlot = Arc; + +/// A **pending seat assignment** the driver applies on its next loop: when a heat is **Staged** the +/// bridge hands the connection the heat's `(node_index, callsign)` bind so the driver seats each +/// bound pilot onto its RH node (`seat_heat`) before racing — so RH records *and* attributes passes +/// (the laps-attribute fix). Shared from the async [`RhConnection::seat`] caller to the blocking +/// driver thread; `None` ⇒ nothing pending. +type SeatSlot = Arc>>>; + +/// The RH heat id **seated** for the current arming, if seating succeeded (the laps-attribute fix): +/// a fresh RH heat built at Stage with the bound pilots assigned + made current, so RH records + +/// attributes passes. Held by [`drive`] **outside** its reconnect loop (not a `maintain`-local) so it +/// **survives a mid-race reconnect**, and shared into [`maintain`] so the finish-time dense save reads +/// it to reuse the seated heat (already current + savable) rather than adding a separate empty heat. +type SeatedHeatSlot = Arc>>; + +/// A heat armed onto a live RH connection: the lineup its node seats remap onto, and a flag the +/// driver flips once it has staged the RH race for this arming (so a re-drain doesn't re-stage). +struct ArmedHeat { + /// The running heat's lineup, in seeding order; node `n`'s passes attribute to `lineup[n]`. + lineup: Vec, + /// The sink (the event's log) translated passes are appended through while armed. + sink: PassSink, + /// Set by the driver once it has staged the RH race for this arming. + staged: bool, + /// Set once RH confirmed **RACING for this arming** (its READY/DONE → RACING transition — + /// the adapter's `SessionStarted`). Lap records drained BEFORE this are a stale replay: + /// a busy RH ("stage while status is not 'ready'") re-broadcasts the PREVIOUS race's + /// `current_laps` snapshot, and remapping those into the new heat recorded the last + /// race's laps as this race's passes (observed live: a fresh heat opening with lap 6+). + /// Lives in the shared slot so a mid-race reconnect (where RH re-sends the LIVE race's + /// snapshot for the carried adapter to dedup) keeps flowing. + started: bool, + /// Set by [`disarm`](RhConnection::disarm) when the heat left `Running`: the driver finishes the + /// heat by **stopping the RH race** (driving it to `DONE`, which auto-triggers the dense + /// `current_marshal_data`/`get_pilotrace` marshal pull — marshaling path-2), keeps routing the + /// resulting [`Event::SignalHistory`] into this heat's sink for a short settle, then clears the + /// slot. Without the stop, RH stays `RACING` between heats and the dense history is never pulled + /// into the finishing heat's log — only the coarse streamed samples survive. + finishing: bool, + /// Set by the driver the instant it has **fired the heat-end dense save** (the + /// `ensure_savable_heat → set_current_heat → stop_race` dance) for this arming, so it runs + /// **exactly once**. This guard lives in the *shared* slot (not a `maintain`-local) deliberately: + /// the dense pull's burst of socket emits can itself drop the link, and on reconnect the driver + /// re-enters [`maintain`] with the same still-`finishing` slot — without this flag it would + /// re-run the whole add_heat/set_current_heat/stop_race dance every reconnect, looping heat after + /// heat, re-flooding+resetting the socket and killing the live race (the #250 regression). Once + /// `done` is set, the finish is never re-triggered by a re-sent `DONE`, a reconnect, or a + /// maintain re-entry; only the (local) settle drain remains, after which the slot is cleared. + done: bool, +} + +/// Render an error together with its full `source()` chain as `"top: cause: root-cause"`. +/// +/// `rust_socketio::Error`'s Display is lossy for the connect path: its +/// `IncompleteResponseFromEngineIo(rust_engineio::Error)` variant carries no `{0}`, so it prints the +/// bare string "EngineIO Error" and drops the wrapped engine.io/reqwest cause — a refused TCP +/// connect, a handshake reject, a TLS failure, and a timeout all collapse to that one opaque line. +/// Walking `std::error::Error::source()` recovers the real reason so a connect-failure log is +/// actionable (e.g. distinguishes "RH not running on :5000 (connection refused)" from a genuine +/// handshake regression). +fn error_chain(err: &dyn std::error::Error) -> String { + let mut out = err.to_string(); + let mut source = err.source(); + while let Some(cause) = source { + out.push_str(": "); + out.push_str(&cause.to_string()); + source = cause.source(); + } + out +} + +/// One persistent live RotorHazard connection for a *(active event, RH timer)* pair (#105). +/// +/// Owns a dedicated `spawn_blocking` driver thread that connects on construction, maintains and +/// monitors the link (reconnecting with backoff on a drop), and drives a race onto the *existing* +/// connection when a heat is armed. The async handle here only flips shared atomics the driver +/// observes; dropping/[`cancel`](Self::cancel)ling it tears the connection down on the driver +/// thread. +pub struct RhConnection { + /// The cancel flag the driver polls; flipped on [`cancel`](Self::cancel) / drop. + cancel: Arc, + /// Set when this connection is being SUPERSEDED by a new one for the same timer (an + /// active-event switch): the exiting driver must then leave the shared timer status alone — + /// its async teardown used to stomp `Disconnected` over the successor's `Connecting`/ + /// `Connected`, and the failover logic read the healthy new primary as down. + yield_status: Arc, + /// The armed-heat slot: `Some` while a heat is racing on this connection, else `None`. + armed: Arc>>, + /// A **pending tune** the driver applies on its next loop (race redesign Slice 4a): the per-node + /// `(node_index, frequency_mhz)` assignment the engine allocated for the staging heat. Set by + /// [`tune`](Self::tune) (called when a heat is Staged), drained + emitted on the driver thread + /// (`set_frequency` per node) so the device tunes its nodes to the assigned channels. + tune: TuneSlot, + /// A **pending prepare** the driver applies on its next loop (Grid owns all timing): set by + /// [`prepare`](Self::prepare) when a heat is **Staged**, the driver zeroes RH's current-format + /// staging and resets RH to READY so the eventual `stage_race` (at Grid's go) starts RH recording + /// instantly, with no RH-side staging hold/tones. + prepare: PrepareSlot, + /// A **pending seat assignment** the driver applies on its next loop (the laps-attribute fix): + /// set by [`seat`](Self::seat) when a heat is **Staged**, the driver seats each bound pilot + /// (`(node_index, callsign)`) onto its RH node (`seat_heat`) so RH records + attributes passes + /// — without it RH races an empty-pilot heat and rejects every crossing ("Pilot not defined"). + seat: SeatSlot, + /// The driver thread's join handle, held so the spawned task is owned by this connection; + /// teardown is cooperative via the `cancel` flag (the thread is blocking, so it cannot be + /// aborted) — dropping the connection flips `cancel` and lets the thread exit on its own. + _driver: JoinHandle<()>, +} + +impl RhConnection { + /// Open a persistent connection for `timer_id` at `url`, publishing status through `timers`. + /// + /// Spawns the driver thread immediately: it sets the timer `Connecting`, connects, then + /// `Connected`, and loops maintaining the link. The connection is idle (monitoring only) until + /// a heat is [armed](Self::arm_heat) onto it. + pub fn open(timer_id: TimerId, url: String, timers: TimerRegistry) -> Self { + let cancel = Arc::new(AtomicBool::new(false)); + let yield_status = Arc::new(AtomicBool::new(false)); + let armed: Arc>> = Arc::new(Mutex::new(None)); + let tune: TuneSlot = Arc::new(Mutex::new(None)); + let prepare: PrepareSlot = Arc::new(AtomicBool::new(false)); + let seat: SeatSlot = Arc::new(Mutex::new(None)); + let driver = { + let cancel = cancel.clone(); + let yield_status = yield_status.clone(); + let armed = armed.clone(); + let tune = tune.clone(); + let prepare = prepare.clone(); + let seat = seat.clone(); + tokio::task::spawn_blocking(move || { + drive( + url, + timer_id, + timers, + cancel, + yield_status, + armed, + tune, + prepare, + seat, + ); + }) + }; + Self { + cancel, + yield_status, + armed, + tune, + prepare, + seat, + _driver: driver, + } + } + + /// **Prepare** this connection for an instant start (Grid owns all timing): the driver zeroes + /// RH's current-format staging delays (no RH-side staging hold/tones) and resets RH to a clean + /// READY state, so the eventual [`arm_heat`](Self::arm_heat) at Grid's go starts RH recording + /// immediately. The bridge calls this when a heat is **Staged** — before the Armed hold and the + /// start tone — so all the reset/format work happens well ahead of go and never races RH's own + /// staging at the start instant (which is what the retired `STAGE_RESET_SETTLE` band-aid fought). + pub fn prepare(&self) { + self.prepare.store(true, Ordering::Relaxed); + } + + /// **Tune** this connection's nodes to an assigned channel plan (race redesign Slice 4a): the + /// engine allocates the channels, the adapter applies them (RE §7.3). `assignment` is the + /// per-node `(node_index, frequency_mhz)` set for the staging heat; the driver thread emits a + /// `set_frequency` per node on its next loop (best-effort — a failed emit on a dropped link is + /// logged, not fatal). The bridge calls this when a heat is **Staged**, before it arms/runs. + pub fn tune(&self, assignment: Vec<(u64, u16)>) { + let mut slot = self.tune.lock().expect("tune lock poisoned"); + *slot = Some(assignment); + } + + /// **Seat** this connection's heat: hand the driver the heat's `(node_index, callsign)` bind so + /// it seats each bound pilot onto its RH node before racing (the laps-attribute fix). The driver + /// builds a fresh RH heat with these pilots assigned and makes it current, so RH records *and* + /// attributes passes on the bound nodes (its pass gate dismisses a crossing on a node with no + /// seated pilot). The bridge calls this when a heat is **Staged**, alongside `prepare`/`tune`, + /// before it arms/runs. `seats` carries one entry per **bound** node; unbound nodes are omitted + /// (left unseated — RH won't record there). An empty `seats` is a no-op (nothing to seat). + pub fn seat(&self, seats: Vec<(u64, String)>) { + let mut slot = self.seat.lock().expect("seat lock poisoned"); + *slot = Some(seats); + } + + /// Arm a running heat onto this live connection: called at Grid's go (the `Armed → Running` + /// instant). The driver emits a single `stage_race` so RH **starts recording immediately** — the + /// connection was already reset to READY with zeroed staging by the Stage-time + /// [`prepare`](Self::prepare), so there is no reset or staging hold here — then routes its + /// translated passes (remapped onto `lineup`) into `sink`'s log. Replaces any previously armed + /// heat (a newer running heat supersedes the prior one). + pub fn arm_heat(&self, lineup: Vec, sink: PassSink) { + let mut slot = self.armed.lock().expect("armed-heat lock poisoned"); + *slot = Some(ArmedHeat { + lineup, + sink, + staged: false, + started: false, + finishing: false, + done: false, + }); + } + + /// Disarm the current heat (it left `Running`): the driver **stops the RH race** so it reaches + /// `DONE` — which auto-triggers RotorHazard's dense marshal-data pull (marshaling path-2) — keeps + /// routing the resulting [`Event::SignalHistory`] into the finishing heat's log for a short + /// settle, then clears the slot. The **connection stays alive** (and keeps reporting status) + /// throughout. A no-op if nothing is armed. Marking `finishing` (rather than nulling the slot + /// outright) is what lets the dense history land in the right heat's log before the slot clears. + pub fn disarm(&self) { + let mut slot = self.armed.lock().expect("armed-heat lock poisoned"); + if let Some(heat) = slot.as_mut() { + heat.finishing = true; + } + } + + /// Tear the connection down: stop any race, disconnect, leave the timer `Disconnected`. Called + /// when the timer is deselected, the active event changes, or the Director shuts down. + pub fn cancel(&self) { + self.cancel.store(true, Ordering::Relaxed); + } + + /// Cancel this connection because a NEW connection for the same timer is replacing it (an + /// active-event switch): the exiting driver yields the shared timer status to its successor + /// (see [`yield_status`](Self::yield_status)). + pub fn cancel_superseded(&self) { + self.yield_status.store(true, Ordering::Relaxed); + self.cancel.store(true, Ordering::Relaxed); + } +} + +impl Drop for RhConnection { + fn drop(&mut self) { + // A dropped connection (the reconcile map removed it) must still tear down on its thread. + self.cancel.store(true, Ordering::Relaxed); + } +} + +/// The RH node index `node-{n}` encodes, if any. Passes from the adapter carry the stable node seat +/// handle; we remap it onto the running heat's lineup by this index. +fn node_index(competitor: &CompetitorRef) -> Option { + competitor.0.strip_prefix("node-")?.parse().ok() +} + +/// Remap one canonical RH [`Event`] onto the heat's lineup and the source adapter id, or `None` to +/// drop it. [`Event::Pass`]es feed the lap projection and the signal facts +/// ([`Event::SignalChunk`]/[`Event::SignalThresholds`], marshaling Slice 1) feed the signal-trace +/// projection; each is keyed on a node seat (`node-{n}`), attributed to `lineup[n]` and re-stamped +/// with `adapter`. Facts for a node outside the lineup (an idle seat) are dropped, as are the +/// adapter's lifecycle / `CompetitorSeen` events (the heat lineup is already established by the +/// control path). +fn remap(event: Event, lineup: &[CompetitorRef], adapter: &AdapterId) -> Option { + match event { + Event::Pass(mut pass) => { + let index = node_index(&pass.competitor)?; + let competitor = lineup.get(index)?.clone(); + pass.adapter = adapter.clone(); + pass.competitor = competitor; + Some(Event::Pass(pass)) + } + Event::SignalChunk(mut chunk) => { + let index = node_index(&chunk.competitor)?; + chunk.competitor = lineup.get(index)?.clone(); + chunk.adapter = adapter.clone(); + Some(Event::SignalChunk(chunk)) + } + Event::SignalThresholds(mut t) => { + let index = node_index(&t.competitor)?; + t.competitor = lineup.get(index)?.clone(); + t.adapter = adapter.clone(); + Some(Event::SignalThresholds(t)) + } + Event::SignalHistory(mut h) => { + // The dense post-race history (RH `current_marshal_data`) is keyed on a node seat exactly + // like a chunk; remap it onto the heat's lineup pilot so the signal-trace projection's + // prefer-dense rule supersedes the coarse streamed chunks for the right competitor. + let index = node_index(&h.competitor)?; + h.competitor = lineup.get(index)?.clone(); + h.adapter = adapter.clone(); + Some(Event::SignalHistory(h)) + } + _ => None, + } +} + +/// Decide whether to fire the heat-end dense save **right now**, and atomically claim it so it can +/// fire **exactly once per arming**. Returns `true` (and flips the heat's shared `done` flag) only +/// when: a heat is armed, it is `finishing`, it has not already fired (`!done`), and no settle is in +/// flight (`!settle_pending`). On every other call it returns `false`. +/// +/// The once-only guarantee lives in the *shared* `done` flag (on [`ArmedHeat`], persisted across +/// reconnects) rather than a `maintain`-local: the dense pull's burst of emits can drop the link, so +/// the driver re-enters [`maintain`] (fresh local `finish_deadline = None`) with the same still- +/// `finishing` slot. Were the guard local, that re-entry would re-run the whole add_heat / +/// set_current_heat / stop_race dance — looping heat after heat, re-flooding+resetting the socket and +/// stopping the live race so no laps land (the #250 regression). Claiming on the shared flag makes a +/// re-sent `DONE`, a reconnect, and a maintain re-entry all no-ops. `settle_pending` blocks a second +/// claim within the *same* invocation while the post-save drain settle is still running. +fn claim_finish(heat: Option<&mut ArmedHeat>, settle_pending: bool) -> bool { + match heat { + Some(h) => claim_finish_flags(h.finishing, &mut h.done, settle_pending), + None => false, + } +} + +/// The pure once-only decision behind [`claim_finish`], over the raw flags so it is unit-testable +/// without a live `ArmedHeat` (which needs a `PassSink`/log). Flips `done` and returns `true` iff +/// the save should fire now: `finishing && !done && !settle_pending`. +fn claim_finish_flags(finishing: bool, done: &mut bool, settle_pending: bool) -> bool { + if finishing && !*done && !settle_pending { + *done = true; + true + } else { + false + } +} + +/// The persistent driver: connect → `Connected` → maintain/monitor → reconnect on drop, until +/// cancelled, then disconnect and leave `Disconnected` (#105). Runs on a dedicated blocking thread. +#[allow(clippy::too_many_arguments)] +fn drive( + url: String, + timer_id: TimerId, + timers: TimerRegistry, + cancel: Arc, + yield_status: Arc, + armed: Arc>>, + tune: TuneSlot, + prepare: PrepareSlot, + seat: SeatSlot, +) { + let mut backoff = RECONNECT_BACKOFF_MIN; + // The RH heat id **seated** for the current arming (the laps-attribute fix), if seating + // succeeded: a fresh RH heat built at Stage with the bound pilots assigned + made current. Lives + // here in `drive` — **outside** the reconnect loop — so it **survives a mid-race reconnect**: the + // finish-time dense save reads it (in `maintain`) to reuse the seated heat (already current + + // savable) rather than adding a separate empty heat. A `maintain`-local would reset to `None` on + // every reconnect, so the finish would then wrongly add an empty heat and clobber the still- + // current seated one. Cleared when a new prepare begins (a fresh arming). `None` ⇒ no seated heat. + let seated_heat: SeatedHeatSlot = Arc::new(Mutex::new(None)); + // The adapter is created **once** and reused across every (re)connection (#105). Its dedup + + // `last_race_status` must be continuous across a reconnect: on a mid-race drop the running heat + // stays `staged` (it lives in the shared `armed` Mutex, not the adapter), so the staging block + // below does NOT reset RH, and RotorHazard re-sends the in-progress `current_laps` snapshot on + // the new socket. A *fresh* adapter (empty dedup) would re-emit every replayed lap as a Pass, + // and the lap projection — which does not dedup by sequence — would turn those into duplicate + // laps (double-count). Reusing the adapter keeps that snapshot deduped. + // + // Combined invariant with #156 (the RACING-transition dedup reset): + // * Mid-race reconnect: the adapter persists, so `last_race_status == RACING`. RH's re-sent + // `race_status=RACING` is NOT a transition (`previous == Some(RACING)`) → no SessionStarted + // re-emit, no #156 reset → the re-sent `current_laps` are deduped (no double-count). ✓ + // * New race / cross-heat: a real READY/DONE→RACING transition DOES fire #156, resetting dedup + // so the next heat (whose lap_number restarts at 0) ingests its own fresh laps. ✓ + let mut carry_adapter = Some(RotorHazardAdapter::new()); + while !cancel.load(Ordering::Relaxed) { + timers.set_status(&timer_id, TimerStatus::Connecting); + // Reuse the carried adapter (preserving dedup/last_race_status across reconnects); only on + // the first attempt is it `Some` from above — every later iteration re-seeds it from the + // adapter recovered out of the previous connection's `disconnect`. + let adapter = carry_adapter.take().unwrap_or_default(); + let conn = match RotorHazardConnection::connect(&url, adapter) { + Ok(conn) => conn, + Err(e) => { + // The connect attempt failed: surface Error, back off, and retry (unless cancelled). + // The adapter was consumed by the failed `connect`; start the next attempt fresh. + // (A connect failure means no socket and no replayed snapshot, so there is nothing + // to dedup against — a fresh adapter is correct and #156 re-seeds on the next race.) + // + // Log the full error *chain*, not just `rust_socketio`'s top-level Display: its + // `IncompleteResponseFromEngineIo` variant renders as the bare, useless string + // "EngineIO Error" (no `{0}`), which hides the actual cause — a refused TCP connect + // (RH not running / wrong port), an engine.io handshake reject, a TLS fault, or a + // timeout all collapse to the same opaque line. `error_chain` walks `source()` so the + // log tells a dead `:5000` apart from a genuine handshake failure at a glance. + eprintln!( + "gridfpv: RotorHazard connect failed for {:?}: {}", + timer_id.0, + error_chain(&e) + ); + timers.set_status(&timer_id, TimerStatus::Error); + if sleep_unless_cancelled(backoff, &cancel) { + break; + } + backoff = (backoff * 2).min(RECONNECT_BACKOFF_MAX); + continue; + } + }; + timers.set_status(&timer_id, TimerStatus::Connected); + backoff = RECONNECT_BACKOFF_MIN; + + // Probe for the GridFPV plugin (D16, S1): `connect` already emitted `gridfpv_hello`, so + // wait briefly for the `gridfpv_hello_ack`. Present-&-compatible / incompatible / missing + // drives the Director's required-with-guided-install UX. Re-probed on every (re)connect. + let plugin = classify_plugin(conn.wait_for_plugin(PLUGIN_PROBE_TIMEOUT)); + timers.set_plugin(&timer_id, plugin); + + // Maintain the live link until it drops or we are cancelled. + let dropped = maintain(&conn, &cancel, &armed, &tune, &prepare, &seat, &seated_heat); + + // Stop any in-flight race and disconnect on the way out of this connection. `disconnect` + // returns the adapter so the next reconnect reuses its dedup state (the #105 fix). + conn.stop_race().ok(); + carry_adapter = Some(conn.disconnect()); + + if cancel.load(Ordering::Relaxed) { + break; + } + // The link dropped (not a cancel): mark Disconnected and reconnect after a short backoff. + if dropped { + eprintln!( + "gridfpv: RotorHazard connection lost for {:?}; reconnecting", + timer_id.0 + ); + timers.set_status(&timer_id, TimerStatus::Disconnected); + if sleep_unless_cancelled(backoff, &cancel) { + break; + } + backoff = (backoff * 2).min(RECONNECT_BACKOFF_MAX); + } + } + // Cancelled: leave the timer Disconnected (deselected / shutdown) — UNLESS a successor + // connection for this same timer already owns the status (an active-event switch): this + // teardown runs async on the driver thread and used to land AFTER the successor's + // `Connecting`/`Connected`, mislabeling a healthy timer and tripping failover. + if !yield_status.load(Ordering::Relaxed) { + timers.set_status(&timer_id, TimerStatus::Disconnected); + } +} + +/// Classify the GridFPV-plugin handshake result (D16, S1) into the [`PluginPresence`] the timer +/// surfaces: no answer → `Missing` (a stock RH — the guided install applies); an answer whose +/// `gridfpv_*` protocol matches the Director → `Present`; otherwise → `Incompatible` (the guided +/// install offers the matching build). Compatibility is the protocol version only — the plugin and +/// Director build versions can differ freely as long as the wire protocol agrees. +fn classify_plugin(hello: Option) -> PluginPresence { + match hello { + None => PluginPresence::Missing, + Some(h) if h.protocol_version == DIRECTOR_PROTOCOL_VERSION => PluginPresence::Present { + plugin_version: h.plugin_version, + rhapi_version: h.rhapi_version, + capabilities: h.capabilities, + }, + Some(h) => PluginPresence::Incompatible { + reason: format!( + "the timer's GridFPV plugin speaks protocol v{}, but this Director supports v{}", + h.protocol_version, DIRECTOR_PROTOCOL_VERSION + ), + plugin_version: h.plugin_version, + protocol_version: h.protocol_version, + }, + } +} + +/// Maintain one established connection: drain translated events each tick (routing passes into the +/// armed heat's log, or discarding them while idle), stage a freshly-armed heat, and probe liveness +/// when idle. Returns `true` if the link appears to have **dropped** (so the caller reconnects), +/// `false` if it exited because of cancellation. +fn maintain( + conn: &RotorHazardConnection, + cancel: &AtomicBool, + armed: &Mutex>, + tune: &Mutex>>, + prepare: &AtomicBool, + seat: &Mutex>>, + seated_heat: &Mutex>, +) -> bool { + let mut last_activity = Instant::now(); + let mut probed_since_activity = false; + let mut stage_deadline: Option = None; + // Paces the busy-RH stage retry (see STAGE_RETRY_INTERVAL); seeded in the past so the + // first retry fires as soon as it is needed. + let mut last_stage_retry = Instant::now() - STAGE_RETRY_INTERVAL; + // The settle window for a **finishing** heat (disarmed): the deadline by which the heat's sink + // stays armed after the RH race is stopped, so the DONE-triggered dense marshal pull lands in + // the right heat's log before the slot clears. `None` ⇒ no heat is finishing. + let mut finish_deadline: Option = None; + + while !cancel.load(Ordering::Relaxed) { + // The source of truth for a drop (#105): `rust_socketio` runs with `.reconnect(false)`, so a + // dropped socket fires the transport's `close`/`error` handlers, which flip `is_alive` to + // false. (An emit alone can't be trusted — a buffering client returns `Ok` on a dead link.) + if !conn.is_alive() { + return true; + } + + // Apply a pending tune (race redesign Slice 4a): the bridge requested the device tune its + // nodes to the staging heat's assigned channels. Emit a `set_frequency` per node; this is + // best-effort (the engine has already allocated — applying is the adapter's half), so a + // failed emit on a supposedly-live socket signals a drop the caller reconnects from. + let pending_tune = tune.lock().expect("tune lock poisoned").take(); + if let Some(assignment) = pending_tune { + for (node, mhz) in assignment { + if conn.set_frequency(node, mhz).is_err() { + return true; + } + } + } + + // Apply a pending **prepare** (Grid owns all timing): the bridge marked this connection at + // the heat's **Stage** transition — well before Grid's go — so RH can be readied for an + // *instant* start with no RH-side staging hold/tones. Two things, in order: + // 1. zero the current format's staging delays (`prepare_instant_start`) so the eventual + // `stage_race` transitions straight to RACING — no staging tones, no fixed/random start + // delay, and `unlimited_time` so RH never auto-stops (Grid owns the stop); + // 2. reset RH to a clean READY state (`stop_race` + `discard_laps`) so the start emit lands + // from a known-idle device. + // Doing this at Stage (not at go) is what retires the `STAGE_RESET_SETTLE` band-aid: the + // reset and the `stage_race` are now separated by the whole Armed hold (seconds), never the + // same gevent tick, so there is no reset-vs-staging race to settle against. RH also no longer + // runs its own staging sequence on top of Grid's start procedure — Grid's tone is the only go. + if prepare.swap(false, Ordering::Relaxed) { + if conn.prepare_instant_start().is_err() { + return true; + } + conn.stop_race().ok(); + conn.discard_laps().ok(); + // A fresh prepare begins a new arming: drop any prior seated heat so this Stage's seat + // (below) — or the finish-time fallback — applies cleanly. + *seated_heat.lock().expect("seated-heat lock poisoned") = None; + // Drop the reset-era event churn so it isn't remapped as race passes when a heat arms. + let _ = conn.events(); + } + + // Apply a pending **seat** (the laps-attribute fix): the bridge handed this connection the + // heat's `(node_index, callsign)` bind at Stage. Build a fresh RH heat with those pilots + // seated and make it current, so RH **records and attributes** passes on the bound nodes — + // without this RH races an empty-pilot heat and its pass gate dismisses every crossing + // ("Pilot not defined"), the zero-laps bug. We seat AFTER the prepare reset (the reset's + // `stop_race`/`discard_laps` don't touch heat rows, but ordering keeps RH idle while we set + // the current heat). The seated heat is remembered so the finish-time dense save reuses it + // (it is already current + savable) rather than adding a separate empty heat. Best-effort: a + // seating that can't complete (a slow RH) leaves `seated_heat = None` and the flow falls back + // to practice mode, which still records via RH's `current_heat is HEAT_ID_NONE` gate branch. + let pending_seat = seat.lock().expect("seat lock poisoned").take(); + if let Some(seats) = pending_seat { + match conn.seat_heat(&seats) { + Ok(heat_id) => *seated_heat.lock().expect("seated-heat lock poisoned") = heat_id, + // A failed emit on a supposedly-live socket signals a drop. + Err(_) => return true, + } + // Drop the seating churn (heat_data/pilot_data/heat re-emits) so none is remapped as a + // race pass when the heat arms. + let _ = conn.events(); + } + + // Stage a freshly-armed heat once — **exactly at Grid's go** (the bridge arms on the + // `Armed → Running` instant, when Grid's tone fires). The connection was already reset to + // READY with zeroed staging by the Stage-time prepare above, so this is a single `stage_race` + // emit with **no reset and no settle**: RH transitions straight to RACING with no RH-side hold + // or tones. RH's race-start aligns with Grid's go, so each pass's `lap_time_stamp` (relative + // to RH's start) maps onto Grid's race clock — and because Grid derives lap times as + // pass-to-pass deltas, even RH's fixed `RACE_START_DELAY_EXTRA_SECS` prestage (a constant, + // not socket-settable) cancels out and lap times stay correct. + // + // RH's pass gate (`server.py`'s `do_pass_record_callback`) records a crossing only when the + // node has a *seated pilot* on the current heat, OR no heat is current (practice mode): + // `(pilot_id is not None and pilot_id != PILOT_ID_NONE) or current_heat is HEAT_ID_NONE`. + // The Stage-time **seat** above built a fresh heat with the bound pilots seated and made it + // current, so each bound node records AND attributes its passes (and RH's "Racing heat … + // pilots: …" log names the callsigns) — the laps-attribute fix. If seating could not complete + // (`seated_heat` is `None`), the heat stays in practice mode (no current heat), which still + // records via the `current_heat is HEAT_ID_NONE` branch (just unattributed on the RH side — + // GridFPV remaps node→pilot itself). Either way the dense per-tick RSSI history accumulates on + // the node interface during the race; the finish block below persists it (marshaling path-2), + // reusing the seated heat when there is one rather than adding a separate empty heat. + let mut just_staged = false; + let do_stage = { + let slot = armed.lock().expect("armed-heat lock poisoned"); + matches!(slot.as_ref(), Some(heat) if !heat.staged) + }; + if do_stage { + // Re-zero the staging delays of the format RH will ACTUALLY race, right before the + // stage. The Stage-time prepare targeted the format that was current THEN — but + // seating the heat can switch RH's effective format (a heat with a class races the + // CLASS's format, RHRace's class_format_id override), whose stock multi-second + // staging sequence then ran on top of Grid's start procedure: every race began + // ~5-7s after Grid's go (lap stamps stayed self-consistent, so the skew was + // invisible in results — but live laps, tones, and callouts all arrived that much + // late; a DB-bloated RH stretched it past 15s). By now the seat's race_status has + // long since folded, so `prepare_instant_start` targets the effective format — + // and RH is READY here, which `alter_race_format` requires. Idempotent. + if conn.prepare_instant_start().is_err() { + return true; + } + // Drop any churn accumulated since the prepare so it isn't remapped as race passes. + let _ = conn.events(); + if conn.stage_race().is_err() { + // A failed emit on a supposedly-live socket signals a drop. + return true; + } + // Mark the (still-armed) heat staged. A concurrent disarm/re-arm between the check above + // and here is benign: a re-arm reset `staged` to false (we re-stage next loop), a disarm + // cleared the slot (nothing to mark). + let mut slot = armed.lock().expect("armed-heat lock poisoned"); + if let Some(heat) = slot.as_mut() { + heat.staged = true; + } + just_staged = true; + stage_deadline = Some(Instant::now() + STAGE_SETTLE); + // A fresh heat staged over a still-finishing previous one (back-to-back heats): cancel + // any pending finish settle so the new heat's slot is not cleared out from under it. + finish_deadline = None; + } else if armed.lock().expect("armed-heat lock poisoned").is_none() { + // Nothing armed: clear any stale stage wait. + stage_deadline = None; + } else if stage_deadline.is_some() { + // Staged but RH has not confirmed RACING yet (no SessionStarted this arming): a + // busy RH — the previous race's dense save still settling — logs "Attempted to + // stage race while status is not 'ready'" and DROPS the stage on the floor. The + // race would then never start (and, before the pass gate above, the replayed old + // snapshot contaminated the new heat). Re-emit the stage every couple of seconds + // until RH takes it or the settle window gives up. + let needs_restage = { + let slot = armed.lock().expect("armed-heat lock poisoned"); + matches!(slot.as_ref(), Some(heat) if heat.staged && !heat.started) + }; + if needs_restage && last_stage_retry.elapsed() >= STAGE_RETRY_INTERVAL { + last_stage_retry = Instant::now(); + if conn.stage_race().is_err() { + return true; + } + } + } + if just_staged { + last_activity = Instant::now(); + probed_since_activity = false; + } + + // Finish a disarmed heat (marshaling path-2): the bridge marked the armed heat `finishing` + // when it left `Running`. The race ran in practice mode (so live laps recorded), and the + // node interface accumulated the dense per-tick RSSI history throughout. Now, at heat-END, + // make a savable heat current and stop the race: stopping drives RH to DONE, and the + // transport's DONE handler auto-emits `save_laps` -> `race_list` -> `get_pilotrace` (and the + // aggregate `current_race_marshal` on newer RH) — which, with a current heat, persists and + // returns that accumulated history. We keep the heat's sink armed through a settle window so + // the resulting `SignalHistory` lands in THIS heat's log (the full-fidelity trace superseding + // the coarse stream), then clear the slot (the connection stays alive). + { + // Fire the heat-end dense save **exactly once per arming**. The trigger is gated on three + // independent conditions, all of which survive a reconnect: the heat is `finishing`, it + // has not already fired (`!done`, the *shared* guard), and no settle is in flight locally. + // We flip the shared `done` flag the instant we decide to fire — BEFORE any emit — so that + // if the dense pull's emit burst drops the socket and the driver reconnects into a fresh + // `maintain` with the same still-`finishing` slot, `done` is already set and the dance is + // NOT re-run (the #250 looping/flapping regression). This makes the save idempotent: a + // re-sent `DONE`, a reconnect, or a maintain re-entry can never re-create heats/rounds, + // re-flood the socket, or re-stop the live race. + let start_finish = { + let mut slot = armed.lock().expect("armed-heat lock poisoned"); + claim_finish(slot.as_mut(), finish_deadline.is_some()) + }; + if start_finish { + // The Stage-time seat already made a savable heat current (with the bound pilots + // seated), so the DONE-triggered `save_laps` has a current heat to persist into — no + // extra heat needed. Only when there is NO seated heat (seating couldn't complete, so + // the race ran in practice mode) do we add+select a savable heat now, FIRST (while + // still RACING), so the dense history still persists: request add_heat + the heat + // list, wait for the `heat_data` response, then select synchronously on this thread + // (keeping the heat-setup emits ordered and off the socket callback — an + // emit-per-`heat_data` there floods + drops the link). Bounded so a quirky/older RH + // that never answers doesn't stall the finish; the dense pull just no-ops then (the + // coarse trace stands). + let already_seated = seated_heat + .lock() + .expect("seated-heat lock poisoned") + .is_some(); + if !already_seated && conn.ensure_savable_heat().is_ok() { + let select_deadline = Instant::now() + ENSURE_HEAT_TIMEOUT; + loop { + // Keep draining so the `heat_data` handler runs; route any real passes that + // are still trickling in into the (still-armed) heat's log rather than drop + // them. (Use the same routing as the main drain below by deferring it — here + // we only need the handler to fire, so a discard of non-pass churn is fine; + // passes for the finishing heat are rare this late and the main drain catches + // any that remain on the next loop.) + let _ = conn.events(); + if let Some(heat) = conn.take_savable_heat() { + conn.set_current_heat(heat).ok(); + break; + } + if Instant::now() >= select_deadline { + break; + } + if sleep_unless_cancelled(Duration::from_millis(100), cancel) { + return false; + } + } + } + // Drive RH to DONE; the transport's DONE handler issues the dense marshal pull, now + // with a current heat so `save_laps` persists the accumulated history. + conn.stop_race().ok(); + finish_deadline = Some(Instant::now() + FINISH_DRAIN_SETTLE); + } else if finish_deadline.is_none() + && armed + .lock() + .expect("armed-heat lock poisoned") + .as_ref() + .is_some_and(|h| h.done) + { + // The save already fired (`done`) but this `maintain` invocation has no local settle — + // i.e. the link dropped/reconnected mid-settle and we re-entered fresh. Do NOT re-fire + // the dance (the guard above already prevents that); just restart the drain settle so + // any dense `SignalHistory` re-pushed on the new socket still lands in this heat's log, + // and the slot is eventually cleared rather than stranded `done`-but-armed forever. + finish_deadline = Some(Instant::now() + FINISH_DRAIN_SETTLE); + } + if let Some(deadline) = finish_deadline { + if Instant::now() >= deadline { + // Settle elapsed: the dense history has been drained into the heat's log. Clear + // the slot (heat fully disarmed) — the connection stays alive and idle-monitors. + *armed.lock().expect("armed-heat lock poisoned") = None; + finish_deadline = None; + stage_deadline = None; + } + } + } + + // Drain whatever the transport has translated since the last tick. + let drained = conn.events(); + if !drained.is_empty() { + last_activity = Instant::now(); + probed_since_activity = false; + let mut slot = armed.lock().expect("armed-heat lock poisoned"); + if let Some(heat) = slot.as_mut() { + let adapter = heat.sink.adapter().clone(); + let mut saw_start = false; + for event in drained { + if matches!(event, Event::SessionStarted { .. }) { + saw_start = true; + heat.started = true; + } + // Gate LAP RECORDS on RH having gone RACING for THIS arming: anything + // earlier is the previous race's snapshot replayed by a still-busy RH — + // remapping it used to contaminate the fresh heat with the last race's + // laps. (Processed in drain order, so passes in the same batch AFTER the + // RACING transition flow normally; signal facts always flow — a pre-start + // trace baseline is harmless and useful.) + if matches!(event, Event::Pass(_)) && !heat.started { + continue; + } + if let Some(remapped) = remap(event, &heat.lineup, &adapter) { + if heat.sink.append_event(remapped).is_err() { + // The log went away (event dropped at shutdown): stop draining. + return false; + } + } + } + if saw_start { + stage_deadline = None; + } + } + // While idle (nothing armed) the drained events are monitoring-only and discarded. + } else if stage_deadline.map(|d| Instant::now() >= d).unwrap_or(false) { + // Gave up waiting for RACING after staging; keep draining steady-state. + stage_deadline = None; + } else if last_activity.elapsed() >= IDLE_PROBE_INTERVAL && !probed_since_activity { + // A quiet link: probe liveness once. A failed emit means the socket has dropped. + if conn.probe_liveness().is_err() { + return true; + } + probed_since_activity = true; + } + + if sleep_unless_cancelled(DRAIN_INTERVAL, cancel) { + return false; + } + } + false +} + +/// Sleep `dur`, but wake early (in short slices) if `cancel` flips. Returns `true` if it woke +/// because of cancellation (so callers can stop promptly), `false` if it slept the full duration. +fn sleep_unless_cancelled(dur: Duration, cancel: &AtomicBool) -> bool { + let deadline = Instant::now() + dur; + while Instant::now() < deadline { + if cancel.load(Ordering::Relaxed) { + return true; + } + std::thread::sleep(Duration::from_millis(20)); + } + cancel.load(Ordering::Relaxed) +} + +#[cfg(test)] +mod tests { + use super::*; + use gridfpv_events::{SignalChunk, SignalThresholds, SourceTime}; + + fn lineup() -> Vec { + vec![CompetitorRef("Ace".into()), CompetitorRef("Bee".into())] + } + + #[test] + fn remap_attributes_signal_chunk_to_the_lineup_pilot() { + // A trace chunk on node-1 is re-attributed to lineup[1] and re-stamped with the adapter id, + // exactly like a pass — so the signal-trace projection groups it under the right pilot. + let adapter = AdapterId("timer-7".into()); + let chunk = Event::SignalChunk(SignalChunk { + adapter: AdapterId("rotorhazard".into()), + competitor: CompetitorRef("node-1".into()), + from: SourceTime::from_micros(0), + period_micros: 100_000, + rssi: vec![70, 150], + }); + match remap(chunk, &lineup(), &adapter) { + Some(Event::SignalChunk(c)) => { + assert_eq!(c.competitor, CompetitorRef("Bee".into())); + assert_eq!(c.adapter, adapter); + assert_eq!(c.rssi, vec![70, 150]); + } + other => panic!("expected a remapped SignalChunk, got {other:?}"), + } + } + + #[test] + fn remap_attributes_thresholds_and_drops_off_lineup_nodes() { + let adapter = AdapterId("timer-7".into()); + let t = Event::SignalThresholds(SignalThresholds { + adapter: AdapterId("rotorhazard".into()), + competitor: CompetitorRef("node-0".into()), + enter: 90, + exit: 80, + }); + match remap(t, &lineup(), &adapter) { + Some(Event::SignalThresholds(t)) => { + assert_eq!(t.competitor, CompetitorRef("Ace".into())); + assert_eq!(t.adapter, adapter); + } + other => panic!("expected remapped SignalThresholds, got {other:?}"), + } + // A node beyond the (2-seat) lineup is dropped, like an idle-seat pass. + let off = Event::SignalChunk(SignalChunk { + adapter: AdapterId("rotorhazard".into()), + competitor: CompetitorRef("node-5".into()), + from: SourceTime::from_micros(0), + period_micros: 100_000, + rssi: vec![0], + }); + assert!(remap(off, &lineup(), &adapter).is_none()); + } + + /// The heat-end dense save fires **exactly once per arming**, even across a reconnect (#250 + /// regression). The shared `done` flag — not a `maintain`-local — is what guarantees it: a + /// finishing heat claims the save once; a second poll in the same invocation is gated by the + /// settle; and crucially, after a simulated reconnect (the local settle resets to "not pending") + /// the still-`finishing` heat must NOT re-fire because `done` persists in the shared slot. Pre-fix + /// the local-only guard re-ran the add_heat/set_current_heat/stop_race dance on every reconnect, + /// looping heats, flapping the socket, and stopping the live race so no laps landed. + #[test] + fn finish_fires_exactly_once_even_across_a_reconnect() { + // Not finishing yet (heat still Running): never fires. + let mut done = false; + assert!(!claim_finish_flags(false, &mut done, false)); + assert!(!done); + + // Disarm → finishing. The first poll (no settle pending) claims the save and marks it done. + assert!( + claim_finish_flags(true, &mut done, false), + "the first finish poll must fire the save" + ); + assert!(done, "claiming the save must flip the shared `done` flag"); + + // A second poll in the SAME maintain invocation, while the post-save settle is pending, does + // not re-fire (settle_pending = true). + assert!( + !claim_finish_flags(true, &mut done, true), + "the save must not re-fire while the post-save settle is still in flight" + ); + + // Simulate a RECONNECT: the dense pull dropped the link, the driver re-enters `maintain` with + // a fresh local `finish_deadline = None` (settle_pending = false) but the SAME still- + // `finishing` shared slot (`done` already true). It must NOT re-run the dance — this is the + // exact #250 loop the fix closes. + assert!( + !claim_finish_flags(true, &mut done, false), + "a reconnect must NOT re-fire the heat-end save once it has already fired (the #250 loop)" + ); + + // A FRESH arming (a new heat) resets `done` to false in `arm_heat`, so the next real finish + // fires again — the once-only guard is per-arming, not per-connection. + let mut next = false; + assert!( + claim_finish_flags(true, &mut next, false), + "a brand-new arming's finish must fire (the guard is per-arming)" + ); + } + + /// A failed connect to a dead port must produce an *actionable* log: the bare top-level + /// `rust_socketio` Display is "EngineIO Error" (hides the cause), but `error_chain` walks the + /// `source()` chain down to the real reason (here: the refused TCP connect). This is the + /// regression-diagnosis fix — a dead `:5000` no longer looks identical to a handshake failure. + #[test] + fn error_chain_surfaces_the_real_connect_cause() { + // Port 1 is reserved/unused on the loopback, so the connect is refused immediately. + // (`RotorHazardConnection` isn't `Debug`, so match rather than `expect_err`.) + let err = + match RotorHazardConnection::connect("http://127.0.0.1:1", RotorHazardAdapter::new()) { + Ok(_) => panic!("connecting to a dead port must fail"), + Err(e) => e, + }; + let chained = error_chain(&err); + // The top-level Display alone is the useless opaque string... + assert_eq!(err.to_string(), "EngineIO Error"); + // ...but the chain recovers the underlying cause (refused / no connection). + assert!( + chained.len() > "EngineIO Error".len(), + "error_chain must add the underlying cause, got {chained:?}" + ); + let lower = chained.to_lowercase(); + assert!( + lower.contains("refused") || lower.contains("connect"), + "error_chain should name the refused connect, got {chained:?}" + ); + } +} diff --git a/crates/app/tests/director.rs b/crates/app/tests/director.rs new file mode 100644 index 0000000..535f186 --- /dev/null +++ b/crates/app/tests/director.rs @@ -0,0 +1,250 @@ +//! Director-server wiring tests (#13, v0.4 Director wiring). +//! +//! These drive the *exact* router `main` serves — [`gridfpv_app::director::build_app`] +//! over an [`AppState`] — with no real socket, via `tower::ServiceExt::oneshot`. They +//! assert the protocol API is reachable (a `GET /health` 200) and that the static SPA is +//! served with an `index.html` fallback when `GRIDFPV_ASSETS` points at a built dir. The +//! committed tests never depend on the real frontend `dist/`: the SPA case writes its own +//! tiny `index.html` into a temp dir. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use gridfpv_app::director::{ + AssetStatus, asset_status, build_app, default_assets_dir, run_director, +}; +use gridfpv_server::events::EventRegistry; +use http_body_util::BodyExt; +use tower::ServiceExt; + +/// A unique temp directory under the OS temp dir, created fresh for one test. +fn temp_dir(tag: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("gridfpv-director-{tag}-{nanos}")); + std::fs::create_dir_all(&dir).expect("create temp assets dir"); + dir +} + +/// `GET ` against the Director router over an empty in-memory log. +async fn get(assets: &Path, uri: &str) -> (StatusCode, String) { + let registry = EventRegistry::new(None).unwrap(); + let app = build_app(registry, assets); + let response = app + .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(); + (status, String::from_utf8_lossy(&bytes).into_owned()) +} + +#[tokio::test] +async fn health_endpoint_is_served() { + // A non-existent assets dir must not break the API. + let assets = std::env::temp_dir().join("gridfpv-director-does-not-exist"); + let (status, body) = get(&assets, "/health").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, "ok"); +} + +#[tokio::test] +async fn snapshot_endpoint_returns_ok() { + let assets = std::env::temp_dir().join("gridfpv-director-no-assets"); + // The event scope folds the whole (empty) log into idle live state — a 200 either way. + let (status, _body) = get(&assets, "/events/practice/snapshot/event/spring-cup").await; + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn root_serves_index_html_when_assets_present() { + let dir = temp_dir("root"); + let marker = "RD Console
"; + std::fs::write(dir.join("index.html"), marker).unwrap(); + + let (status, body) = get(&dir, "/").await; + assert_eq!(status, StatusCode::OK); + assert!(body.contains("RD Console"), "served the SPA shell: {body}"); + + std::fs::remove_dir_all(&dir).ok(); +} + +#[tokio::test] +async fn unknown_client_route_falls_back_to_index_html() { + let dir = temp_dir("spa-fallback"); + let marker = "RD Console"; + std::fs::write(dir.join("index.html"), marker).unwrap(); + + // A deep client-side route (no such file) must resolve to the SPA shell, not 404. + let (status, body) = get(&dir, "/heats/q-1/live").await; + assert_eq!(status, StatusCode::OK); + assert!( + body.contains("RD Console"), + "fell back to index.html: {body}" + ); + + std::fs::remove_dir_all(&dir).ok(); +} + +#[tokio::test] +async fn static_assets_are_served_alongside_index() { + let dir = temp_dir("assets"); + std::fs::write(dir.join("index.html"), "shell").unwrap(); + std::fs::write(dir.join("app.js"), "console.log('rd')").unwrap(); + + let (status, body) = get(&dir, "/app.js").await; + assert_eq!(status, StatusCode::OK); + assert!(body.contains("console.log"), "served the JS asset: {body}"); + + std::fs::remove_dir_all(&dir).ok(); +} + +#[tokio::test] +async fn cors_preflight_is_permissive() { + let dir = temp_dir("cors"); + std::fs::write(dir.join("index.html"), "shell").unwrap(); + + let registry = EventRegistry::new(None).unwrap(); + let app = build_app(registry, &dir); + let response = app + .oneshot( + Request::builder() + .method("OPTIONS") + .uri("/events/practice/snapshot/event/spring-cup") + .header("Origin", "http://tauri.localhost") + .header("Access-Control-Request-Method", "GET") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + // A permissive CORS layer answers the preflight with an allow-origin header so the + // cross-origin Tauri RD app may call the API + open the WS. + assert!( + response + .headers() + .contains_key("access-control-allow-origin"), + "permissive CORS echoes an allow-origin header" + ); + + std::fs::remove_dir_all(&dir).ok(); +} + +/// The **embedded-Director path** the Tauri native app relies on: [`run_director`] binds a +/// loopback **ephemeral** port (`127.0.0.1:0`), reports the OS-assigned port via `on_ready`, +/// and answers `/health` over a real socket — all with **no display**, proving the server +/// half of the desktop app is sound on a headless VM (the GUI window is the only piece that +/// needs a display). A `oneshot`-driven graceful shutdown then stops it cleanly. +#[tokio::test] +async fn run_director_serves_health_on_loopback_ephemeral_port() { + let dir = temp_dir("embedded"); + std::fs::write(dir.join("index.html"), "RD Console").unwrap(); + + // Capture the bound address `run_director` reports — this is exactly what the Tauri app + // reads (`ready.bound.port()`) to point its window at `http://127.0.0.1:`. + let bound: Arc>> = Arc::new(Mutex::new(None)); + let bound_for_cb = bound.clone(); + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + // Run the SAME entry point the desktop app uses, on loopback + an ephemeral port, with no + // configured token (⇒ open control, the loopback-trust model) and an in-memory registry. + let server = tokio::spawn(async move { + // `Box` isn't `Send`, so flatten to a `String` for the join across tasks. + run_director( + SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), + None, + dir, + move |ready| { + *bound_for_cb.lock().unwrap() = Some(ready.bound); + }, + async move { + let _ = shutdown_rx.await; + }, + ) + .await + .map_err(|e| e.to_string()) + }); + + // Poll briefly for `on_ready` to record the bound (ephemeral) address. + let addr = { + let mut found = None; + for _ in 0..100 { + if let Some(addr) = *bound.lock().unwrap() { + found = Some(addr); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + found.expect("run_director reported its bound address via on_ready") + }; + + assert!(addr.ip().is_loopback(), "bound to loopback: {addr}"); + assert_ne!(addr.port(), 0, "an ephemeral port was assigned: {addr}"); + + // Hit the real socket: the embedded Director answers /health with a 200 "ok". + let body = reqwest_get(&format!("http://{addr}/health")).await; + assert_eq!( + body, "ok", + "embedded Director answers /health over loopback" + ); + + // Graceful shutdown via the oneshot trigger, mirroring the app's lifetime model. + let _ = shutdown_tx.send(()); + server + .await + .expect("server task joins") + .expect("run_director returns Ok after graceful shutdown"); +} + +/// A tiny dependency-free HTTP GET over a TcpStream — enough to read a short `/health` body +/// without pulling an HTTP client into the app crate's dev-deps. +async fn reqwest_get(url: &str) -> String { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let addr = url.strip_prefix("http://").unwrap(); + let (host_port, path) = match addr.find('/') { + Some(i) => (&addr[..i], &addr[i..]), + None => (addr, "/"), + }; + let mut stream = tokio::net::TcpStream::connect(host_port).await.unwrap(); + let req = format!("GET {path} HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\n\r\n"); + stream.write_all(req.as_bytes()).await.unwrap(); + let mut buf = Vec::new(); + stream.read_to_end(&mut buf).await.unwrap(); + let text = String::from_utf8_lossy(&buf); + // The body follows the blank line after the headers. + text.split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .trim() + .to_string() +} + +#[test] +fn asset_status_classifies_the_dir() { + let missing = std::env::temp_dir().join("gridfpv-director-absent-xyz"); + assert_eq!(asset_status(&missing), AssetStatus::Missing); + + let dir = temp_dir("status"); + assert_eq!(asset_status(&dir), AssetStatus::NoIndex); + std::fs::write(dir.join("index.html"), "x").unwrap(); + assert_eq!(asset_status(&dir), AssetStatus::Built); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn default_assets_dir_points_at_rd_console_dist() { + let dir = default_assets_dir(); + assert!( + dir.ends_with("frontend/apps/rd-console/dist"), + "default assets resolve under the workspace frontend: {}", + dir.display() + ); +} diff --git a/crates/app/tests/fixtures/rotorhazard.laps.json b/crates/app/tests/fixtures/rotorhazard.laps.json index 655f16e..634f6bf 100644 --- a/crates/app/tests/fixtures/rotorhazard.laps.json +++ b/crates/app/tests/fixtures/rotorhazard.laps.json @@ -1,15 +1,33 @@ { "competitors": [ { - "competitor": { "adapter": "rotorhazard", "competitor": "node-0" }, + "competitor": { + "adapter": "rotorhazard", + "competitor": "node-0" + }, "laps": [ - { "number": 1, "duration_micros": 5201223 }, - { "number": 2, "duration_micros": 2601166 } + { + "number": 1, + "duration_micros": 5201223, + "at": 7416519, + "start_ref": 5, + "end_ref": 14 + }, + { + "number": 2, + "duration_micros": 2601166, + "at": 10017685, + "start_ref": 14, + "end_ref": 18 + } ] }, { - "competitor": { "adapter": "rotorhazard", "competitor": "node-1" }, + "competitor": { + "adapter": "rotorhazard", + "competitor": "node-1" + }, "laps": [] } ] -} +} \ No newline at end of file diff --git a/crates/app/tests/fixtures/sprint.laps.json b/crates/app/tests/fixtures/sprint.laps.json index d4c12ab..01f9acc 100644 --- a/crates/app/tests/fixtures/sprint.laps.json +++ b/crates/app/tests/fixtures/sprint.laps.json @@ -1,17 +1,41 @@ { "competitors": [ { - "competitor": { "adapter": "sim", "competitor": "Ace" }, + "competitor": { + "adapter": "sim", + "competitor": "Ace" + }, "laps": [ - { "number": 1, "duration_micros": 30000000 }, - { "number": 2, "duration_micros": 31000000 } + { + "number": 1, + "duration_micros": 30000000, + "at": 30000000, + "start_ref": 3, + "end_ref": 6 + }, + { + "number": 2, + "duration_micros": 31000000, + "at": 61000000, + "start_ref": 6, + "end_ref": 8 + } ] }, { - "competitor": { "adapter": "sim", "competitor": "Bee" }, + "competitor": { + "adapter": "sim", + "competitor": "Bee" + }, "laps": [ - { "number": 1, "duration_micros": 33000000 } + { + "number": 1, + "duration_micros": 33000000, + "at": 38000000, + "start_ref": 4, + "end_ref": 7 + } ] } ] -} +} \ No newline at end of file diff --git a/crates/app/tests/fixtures/velocidrone.laps.json b/crates/app/tests/fixtures/velocidrone.laps.json index d6c6d2d..70a8b3d 100644 --- a/crates/app/tests/fixtures/velocidrone.laps.json +++ b/crates/app/tests/fixtures/velocidrone.laps.json @@ -1,17 +1,41 @@ { "competitors": [ { - "competitor": { "adapter": "velocidrone", "competitor": "Ace" }, + "competitor": { + "adapter": "velocidrone", + "competitor": "Ace" + }, "laps": [ - { "number": 1, "duration_micros": 12000000 }, - { "number": 2, "duration_micros": 13000000 } + { + "number": 1, + "duration_micros": 12000000, + "at": 14000000, + "start_ref": 3, + "end_ref": 6 + }, + { + "number": 2, + "duration_micros": 13000000, + "at": 27000000, + "start_ref": 6, + "end_ref": 8 + } ] }, { - "competitor": { "adapter": "velocidrone", "competitor": "Bee" }, + "competitor": { + "adapter": "velocidrone", + "competitor": "Bee" + }, "laps": [ - { "number": 1, "duration_micros": 13000000 } + { + "number": 1, + "duration_micros": 13000000, + "at": 15500000, + "start_ref": 4, + "end_ref": 7 + } ] } ] -} +} \ No newline at end of file diff --git a/crates/app/tests/race_flow.rs b/crates/app/tests/race_flow.rs new file mode 100644 index 0000000..1dfcf20 --- /dev/null +++ b/crates/app/tests/race_flow.rs @@ -0,0 +1,1511 @@ +//! The Director-API **race-flow e2e** (race redesign Slice 3a) — the regression net for +//! the round-driven engine across Slices 3–6. +//! +//! This drives the realistic *mock-race* path end to end against the exact router `main` +//! serves ([`build_app`]) plus the **per-event source bridge** ([`spawn_registry_bridge`]) +//! running the built-in **Mock** timer, so it exercises the whole keystone slice: +//! +//! 1. `POST /events` → select a class → set its membership → define a `timed_qual` round +//! (`POST …/rounds`) → make the event active. +//! 2. `FillRound` (`POST …/control`) draws the first heat from the class membership; the +//! test drives `Stage → Arm → Start`, the **mock bridge emits laps**, then +//! `Finish → Score`. +//! 3. `FillRound` again → the 1-round qual is **Complete**, with a final ranking. +//! 4. A second **bracket** round seeded `FromRanking(top 2)` of the qual is `FillRound`ed +//! and its first heat lines up the **top two of the qual ranking** — the bracket carry. +//! +//! Assertions are over the **log** (read through the event's own `AppState`, which the +//! bridge shares): each round-scheduled `HeatScheduled` carries the right `round`/`class`, +//! the lineup matches the class membership / the qual top-2, the qual completes, and a +//! ranking is produced. Lap *timing* is mock-paced so the test waits **by condition** (poll +//! the log for the expected passes) rather than by fixed sleeps — fast + deterministic. + +use std::time::Duration; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use gridfpv_app::director::build_app; +use gridfpv_app::source::{SIM_ADAPTER, SimSource, SourceConfig, spawn_registry_bridge}; +use gridfpv_engine::heat::GraceWindow; +use gridfpv_events::{AdapterId, ClassId, Event, HeatId, RoundId}; +use gridfpv_server::app::AppState; +use gridfpv_server::classes::CreateClassRequest; +use gridfpv_server::control::{Command, CommandAck, FillMode}; +use gridfpv_server::events::{ + ChannelMode, EventRegistry, MemberSlot, NewRoundReq, RoundDef, SeedingRule, StartProcedure, +}; +use gridfpv_server::pilots::CreatePilotRequest; +use gridfpv_server::scope::EventId; +use gridfpv_server::timers::{MOCK_TIMER_ID, TimerId, TimerKind, UpdateTimerRequest}; +use http_body_util::BodyExt; +use std::collections::BTreeMap; +use tower::ServiceExt; + +/// A throwaway assets dir (the e2e never serves the SPA). +fn no_assets() -> std::path::PathBuf { + std::env::temp_dir().join("gridfpv-race-flow-no-assets") +} + +/// Build a registry whose built-in Mock runs a tiny, fast heat so the whole flow finishes +/// in a few ms (the bridge poll interval dominates, so keep laps small). +fn fast_registry(laps: u32, lap_ms: u64) -> EventRegistry { + let registry = EventRegistry::new(None).unwrap(); + registry + .timers() + .update( + &TimerId(MOCK_TIMER_ID.to_string()), + &UpdateTimerRequest { + name: None, + kind: Some(TimerKind::Mock { laps, lap_ms }), + ..Default::default() + }, + ) + .unwrap(); + registry +} + +/// One JSON request against the Director router; returns the status and the body string. +async fn call( + app: &axum::Router, + method: &str, + uri: &str, + token: Option<&str>, + body: Option, +) -> (StatusCode, String) { + let mut builder = Request::builder().method(method).uri(uri); + if let Some(t) = token { + builder = builder.header("Authorization", format!("Bearer {t}")); + } + let request = match body { + Some(json) => builder + .header("Content-Type", "application/json") + .body(Body::from(json.to_string())) + .unwrap(), + None => builder.body(Body::empty()).unwrap(), + }; + let response = app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + (status, String::from_utf8_lossy(&bytes).into_owned()) +} + +/// Send a control `Command` and assert it acked ok. +async fn control_ok(app: &axum::Router, event: &EventId, token: &str, command: &Command) { + let (status, body) = call( + app, + "POST", + &format!("/events/{}/control", event.0), + Some(token), + Some(serde_json::to_value(command).unwrap()), + ) + .await; + assert_eq!(status, StatusCode::OK, "control HTTP failed: {body}"); + let ack: CommandAck = serde_json::from_str(&body).unwrap(); + assert!(ack.ok, "command {command:?} was rejected: {ack:?}"); +} + +/// Read the whole event log through its shared `AppState`. +fn read_log(state: &AppState) -> Vec { + state + .log() + .lock() + .unwrap() + .read_all() + .unwrap() + .into_iter() + .map(|s| s.event) + .collect() +} + +/// Poll the event log until `cond` holds or fail after `deadline` — deterministic-by-condition. +async fn wait_until(state: &AppState, deadline: Duration, mut cond: impl FnMut(&[Event]) -> bool) { + let start = std::time::Instant::now(); + loop { + if cond(&read_log(state)) { + return; + } + if start.elapsed() > deadline { + panic!("race-flow condition not met within {deadline:?}"); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } +} + +/// The lineup of the most recent `HeatScheduled` tagged with `round`, plus its class. +fn round_heat(events: &[Event], round: &str) -> Option<(HeatId, Option, Vec)> { + let mut found = None; + for e in events { + if let Event::HeatScheduled { + heat, + lineup, + class, + round: Some(r), + .. + } = e + { + if r.0 == round { + found = Some(( + heat.clone(), + class.clone(), + lineup.iter().map(|c| c.0.clone()).collect(), + )); + } + } + } + found +} + +/// Lap-gate passes attributed to a heat's run window (between its Running and the next +/// terminal transition) — used to wait for the mock to finish emitting before scoring. +fn passes_in_running_window(events: &[Event]) -> usize { + let mut running = false; + let mut count = 0usize; + for e in events { + match e { + Event::HeatStateChanged { transition, .. } => match transition { + gridfpv_events::HeatTransition::Running => running = true, + gridfpv_events::HeatTransition::Finished + | gridfpv_events::HeatTransition::Finalized + | gridfpv_events::HeatTransition::Aborted + | gridfpv_events::HeatTransition::Restarted => running = false, + _ => {} + }, + Event::Pass(p) if running && p.gate.is_lap_gate() => count += 1, + _ => {} + } + } + count +} + +/// Drive one round-scheduled heat through the full loop with the mock bridge emitting laps: +/// FillRound → Stage → Start → SkipCountdown → (wait for laps) → ForceEnd → Finalize. Returns the +/// heat id. +/// +/// Heat-lifecycle Slice 2 collapsed the manual `Arm`/`Start`/`Finish` commands: `Start` now arms the +/// heat and the runtime auto-advances `Armed → Running` (after the round's start delay) and +/// `Running → Unofficial` (on the win condition + grace). This e2e uses the **overrides** +/// `SkipCountdown` / `ForceEnd` to bypass those timers so it stays fast and deterministic regardless +/// of the round's start-delay / win-condition config (this round scores `BestLap`, which has no +/// intrinsic auto-completion). A dedicated test drives the *auto* path +/// ([`heat_auto_advances_running_to_unofficial_under_the_clock`]). +async fn run_one_heat( + app: &axum::Router, + state: &AppState, + event: &EventId, + token: &str, + round: &str, + pilots: usize, + laps: u32, +) -> HeatId { + // FillRound draws + schedules the next heat. + control_ok( + app, + event, + token, + &Command::FillRound { + round: RoundId(round.into()), + mode: FillMode::Next, + }, + ) + .await; + let (heat, _class, lineup) = { + let events = read_log(state); + round_heat(&events, round).expect("FillRound scheduled a heat for the round") + }; + assert_eq!(lineup.len(), pilots, "the heat lineup is the round field"); + + control_ok(app, event, token, &Command::Stage { heat: heat.clone() }).await; + // `Start` arms the heat (Staged → Armed) and runs the start procedure; `SkipCountdown` forces + // Armed → Running immediately so the e2e doesn't wait on the randomized start delay. + control_ok(app, event, token, &Command::Start { heat: heat.clone() }).await; + control_ok( + app, + event, + token, + &Command::SkipCountdown { heat: heat.clone() }, + ) + .await; + + // The mock bridge emits a holeshot + `laps` lap-gate passes per pilot. Wait until they + // have all landed before closing the heat. + let want = pilots * (laps as usize + 1); + wait_until(state, Duration::from_secs(10), move |events| { + passes_in_running_window(events) >= want + }) + .await; + + // `ForceEnd` closes the race (Running → Unofficial) — the override for the auto-completion. + control_ok(app, event, token, &Command::ForceEnd { heat: heat.clone() }).await; + control_ok(app, event, token, &Command::Finalize { heat: heat.clone() }).await; + heat +} + +#[tokio::test] +async fn round_driven_mock_race_flow_e2e() { + // A 1-lap, fast mock so the heat finishes quickly and deterministically-by-condition. + let laps = 1u32; + let registry = fast_registry(laps, 2); + + // Seed the directory: a class + four pilots the round will field. + let class_id = registry + .classes() + .create(&CreateClassRequest { + name: "Open".into(), + source: Default::default(), + reference: None, + description: None, + }) + .unwrap() + .id; + let mut pilots = Vec::new(); + for cs in ["alpha", "bravo", "charlie", "delta"] { + let p = registry + .pilots() + .create(&CreatePilotRequest { + callsign: cs.into(), + ..Default::default() + }) + .unwrap(); + pilots.push(p.id); + } + + let token = registry.tokens().issue_rd_token(); + + // Spawn the per-event source bridge (runs the Mock on a Running transition) and the + // Director router over the same registry. + let _bridge = spawn_registry_bridge( + registry.clone(), + SourceConfig::Sim(SimSource::new(laps, Duration::from_millis(2))), + AdapterId(SIM_ADAPTER.to_string()), + ); + let app = build_app(registry.clone(), &no_assets()); + + // 1) Create the event over HTTP. + let (status, body) = call( + &app, + "POST", + "/events", + Some(&token), + Some(serde_json::json!({ "name": "Race Flow" })), + ) + .await; + assert_eq!(status, StatusCode::OK, "create event: {body}"); + let event_meta: serde_json::Value = serde_json::from_str(&body).unwrap(); + let event = EventId(event_meta["id"].as_str().unwrap().to_string()); + let state = registry.resolve(&event).unwrap(); + + // 2) Roster the pilots (membership is scoped to the event roster), then select the class. + control_put( + &app, + &format!("/events/{}/roster", event.0), + &token, + serde_json::json!({ "pilot_ids": pilots.iter().map(|p| p.0.clone()).collect::>() }), + ) + .await; + control_put( + &app, + &format!("/events/{}/classes", event.0), + &token, + serde_json::json!({ "ids": [class_id.0] }), + ) + .await; + + // 3) Set the class membership = the four pilots (this is the round's field). + control_put( + &app, + &format!("/events/{}/classes/{}/membership", event.0, class_id.0), + &token, + serde_json::json!({ "pilots": pilots.iter().map(|p| p.0.clone()).collect::>() }), + ) + .await; + + // 4) Define a single-round timed_qual round over the class. + let qual: RoundDef = add_round( + &app, + &event, + &token, + NewRoundReq { + label: "Qualifying".into(), + classes: vec![class_id.clone()], + format: "timed_qual".into(), + params: BTreeMap::from([("rounds".into(), "1".into())]), + win_condition: Some(gridfpv_engine::scoring::WinCondition::BestLap), + // Best-lap only ranks; a scored round needs a race time to end (server validation). + time_limit_secs: Some(60), + seeding: SeedingRule::FromRoster, + // Per-heat: this flow asserts the whole-field heat + first-fit channel assignment. + channel_mode: Some(ChannelMode::PerHeat), + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }, + ) + .await; + + // Make the event active (so the bridge's failover/selection reads resolve cleanly). + control_put( + &app, + "/active-event", + &token, + serde_json::json!({ "id": event.0 }), + ) + .await; + + // --- Drive the qual round's one heat to Score, mock bridge emitting laps. --- + let qheat = run_one_heat(&app, &state, &event, &token, &qual.id.0, pilots.len(), laps).await; + + // The scheduled heat carried the round + the single class, and its lineup is the + // class membership (pilot ids mapped to competitor refs, in membership order). + let events = read_log(&state); + let (sched_heat, sched_class, sched_lineup) = round_heat(&events, &qual.id.0).unwrap(); + assert_eq!(sched_heat, qheat); + assert_eq!(sched_class, Some(ClassId(class_id.0.clone()))); + assert_eq!( + sched_lineup, + pilots.iter().map(|p| p.0.clone()).collect::>(), + "the heat lineup matches the class members in membership order" + ); + + // Race redesign Slice 4a: the IRL heat carries per-pilot channel assignments from the Mock + // timer's available set (8 nodes, seeded Raceband). Four pilots → R1..R4 in seed order. + let freqs = events + .iter() + .rev() + .find_map(|e| match e { + Event::HeatScheduled { + heat, frequencies, .. + } if *heat == sched_heat && !frequencies.is_empty() => Some(frequencies.clone()), + _ => None, + }) + .expect("the filled heat carries an assigned frequency set"); + assert_eq!(freqs.len(), pilots.len(), "every pilot gets a channel"); + assert_eq!(freqs[0].1, 5658, "top seed gets Raceband R1"); + assert_eq!(freqs[1].1, 5695, "second seed gets Raceband R2"); + // Each assigned competitor matches the lineup, in seed order. + for (i, p) in pilots.iter().enumerate() { + assert_eq!(freqs[i].0.0, p.0, "frequency assigned in seed order"); + } + + // --- FillRound again: the 1-round qual is now Complete (acks ok, schedules nothing new). --- + let before = read_log(&state).len(); + control_ok( + &app, + &event, + &token, + &Command::FillRound { + round: qual.id.clone(), + mode: FillMode::Next, + }, + ) + .await; + let after = read_log(&state).len(); + assert_eq!( + before, after, + "a completed round appends no new heat on a further FillRound" + ); + // Exactly one heat was ever scheduled for the qual round. + let qual_heats = read_log(&state) + .iter() + .filter(|e| matches!(e, Event::HeatScheduled { round: Some(r), .. } if *r == qual.id)) + .count(); + assert_eq!(qual_heats, 1, "the 1-round qual scheduled exactly one heat"); + + // The qual produced a final ranking (best lap first) — assert it ranks the whole field. + let qual_round = registry.rounds_of(&event).unwrap()[0].clone(); + let ranking = gridfpv_server::round_engine::round_ranking( + ®istry.meta_of(&event).unwrap(), + &qual_round, + &events, + ) + .unwrap(); + assert_eq!(ranking.len(), pilots.len(), "the ranking covers the field"); + assert_eq!(ranking[0].position, 1); + + // --- A second round seeded FromRanking(top 2) of the qual — the bracket carry. --- + let bracket: RoundDef = add_round( + &app, + &event, + &token, + NewRoundReq { + label: "Bracket".into(), + classes: vec![class_id.clone()], + format: "head_to_head".into(), + params: BTreeMap::new(), + win_condition: Some(gridfpv_engine::scoring::WinCondition::FirstToLaps { n: laps }), + time_limit_secs: None, + seeding: SeedingRule::FromRanking { + source_rounds: vec![qual.id.clone()], + top_n: 2, + }, + // head_to_head defaults to PerHeat anyway; keep it explicit for the bracket carry. + channel_mode: Some(ChannelMode::PerHeat), + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }, + ) + .await; + + // FillRound the bracket: its first heat lines up the top-2 of the qual ranking. + control_ok( + &app, + &event, + &token, + &Command::FillRound { + round: bracket.id.clone(), + mode: FillMode::Next, + }, + ) + .await; + let events = read_log(&state); + let (_bheat, bclass, blineup) = + round_heat(&events, &bracket.id.0).expect("FillRound scheduled the bracket's first heat"); + assert_eq!(bclass, Some(ClassId(class_id.0.clone()))); + let expected_top2: Vec = ranking + .iter() + .take(2) + .map(|e| e.competitor.0.clone()) + .collect(); + assert_eq!( + blineup, expected_top2, + "the bracket's first heat seeds from the qual ranking (top 2)" + ); + + // --- Race redesign Slice 5/6a: the round-ranking + class-standings read routes. --- + + // 1) GET …/rounds/{round}/ranking returns the round's ranking, in the SAME order the engine + // seeds `FromRanking` from — so the served ranking == the bracket's seeding source. + let (status, body) = call( + &app, + "GET", + &format!("/events/{}/rounds/{}/ranking", event.0, qual.id.0), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "round ranking: {body}"); + let served: Vec = serde_json::from_str(&body).unwrap(); + let served_names: Vec = served.iter().map(|e| e.competitor.0.clone()).collect(); + let engine_names: Vec = ranking.iter().map(|e| e.competitor.0.clone()).collect(); + assert_eq!( + served_names, engine_names, + "the route's ranking matches the engine's FromRanking seeding order" + ); + // The bracket's lineup (the top-2 carry) is exactly the served ranking's top-2. + assert_eq!( + blineup, + served_names.iter().take(2).cloned().collect::>(), + "a FromRanking bracket's seeding equals the served round ranking" + ); + + // 2) GET …/classes/{class}/standings aggregates the class's rounds: one row per pilot, best + // first, with the qual round's points (4-pilot field → 4..1). The class's two rounds + // (qual + bracket) both feed the standings, so positions reflect the aggregate. + let (status, body) = call( + &app, + "GET", + &format!("/events/{}/classes/{}/standings", event.0, class_id.0), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "class standings: {body}"); + let standings: gridfpv_server::round_engine::ClassStandings = + serde_json::from_str(&body).unwrap(); + assert_eq!(standings.class.0, class_id.0); + assert_eq!( + standings.standings.len(), + pilots.len(), + "every class pilot has a standings row" + ); + // The top of the standings is the qual winner (they also won/led the bracket carry). + assert_eq!( + standings.standings[0].competitor.0, engine_names[0], + "the standings leader is the qual ranking leader" + ); + assert_eq!(standings.standings[0].position, 1); + // The standings best lap is derived from the heats' real laps (the lap-list projection), so a + // pilot who completed laps reports a non-null minimum — not the null the old metric-reading + // left for non-BestLap (FirstToLaps bracket) rounds. + assert!( + standings.standings[0] + .best_lap_micros + .is_some_and(|micros| micros > 0), + "the standings leader has a real best lap, not null: {:?}", + standings.standings[0], + ); + // Standings are ordered by points (descending) — non-increasing down the list. + for pair in standings.standings.windows(2) { + assert!( + pair[0].points >= pair[1].points, + "standings are ordered by points descending" + ); + } +} + +/// Race redesign Slice 4a: a `FillRound` whose lineup exceeds the timer's node count is rejected +/// (the heat-size cap) and appends no heat, end to end against the Director router. +#[tokio::test] +async fn fill_round_rejects_an_oversized_heat_e2e() { + let registry = fast_registry(1, 2); + + // Retune the Mock to a 2-node timer (the heat-size cap), keeping Raceband available. + registry + .timers() + .update( + &TimerId(MOCK_TIMER_ID.to_string()), + &UpdateTimerRequest { + node_count: Some(2), + ..Default::default() + }, + ) + .unwrap(); + + // A class with four pilots — over the 2-node cap. + let class_id = registry + .classes() + .create(&CreateClassRequest { + name: "Open".into(), + source: Default::default(), + reference: None, + description: None, + }) + .unwrap() + .id; + let mut pilots = Vec::new(); + for cs in ["a", "b", "c", "d"] { + pilots.push( + registry + .pilots() + .create(&CreatePilotRequest { + callsign: cs.into(), + ..Default::default() + }) + .unwrap() + .id, + ); + } + let token = registry.tokens().issue_rd_token(); + let app = build_app(registry.clone(), &no_assets()); + + let (status, body) = call( + &app, + "POST", + "/events", + Some(&token), + Some(serde_json::json!({ "name": "Oversized" })), + ) + .await; + assert_eq!(status, StatusCode::OK, "create event: {body}"); + let event = EventId( + serde_json::from_str::(&body).unwrap()["id"] + .as_str() + .unwrap() + .to_string(), + ); + let state = registry.resolve(&event).unwrap(); + + control_put( + &app, + &format!("/events/{}/roster", event.0), + &token, + serde_json::json!({ "pilot_ids": pilots.iter().map(|p| p.0.clone()).collect::>() }), + ) + .await; + control_put( + &app, + &format!("/events/{}/classes", event.0), + &token, + serde_json::json!({ "ids": [class_id.0] }), + ) + .await; + control_put( + &app, + &format!("/events/{}/classes/{}/membership", event.0, class_id.0), + &token, + serde_json::json!({ "pilots": pilots.iter().map(|p| p.0.clone()).collect::>() }), + ) + .await; + let round: RoundDef = add_round( + &app, + &event, + &token, + NewRoundReq { + label: "Qualifying".into(), + classes: vec![class_id.clone()], + format: "timed_qual".into(), + params: BTreeMap::from([("rounds".into(), "1".into())]), + win_condition: Some(gridfpv_engine::scoring::WinCondition::BestLap), + // Best-lap only ranks; a scored round needs a race time to end (server validation). + time_limit_secs: Some(60), + seeding: SeedingRule::FromRoster, + // Per-heat: this flow asserts the node-cap rejection of an oversized whole-field heat. + channel_mode: Some(ChannelMode::PerHeat), + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }, + ) + .await; + + // FillRound: the 4-pilot field exceeds the 2-node cap → a rejected command, nothing appended. + let before = read_log(&state).len(); + let (http, body) = call( + &app, + "POST", + &format!("/events/{}/control", event.0), + Some(&token), + Some( + serde_json::to_value(Command::FillRound { + round: round.id, + mode: FillMode::Next, + }) + .unwrap(), + ), + ) + .await; + assert_eq!(http, StatusCode::OK, "control HTTP: {body}"); + let ack: CommandAck = serde_json::from_str(&body).unwrap(); + assert!(!ack.ok, "an oversized heat must be rejected: {ack:?}"); + let after = read_log(&state).len(); + assert_eq!(before, after, "a rejected FillRound appends no heat"); +} + +#[tokio::test] +async fn static_channel_balanced_qual_flow_e2e() { + // A **static** qual round (race redesign Slice 7a): six members across three Raceband channels + // on a 2-node timer (channels > node_count). The channel-balanced builder forms heats of ≤2 + // pilots on distinct channels off each member's fixed channel; every member flies, and each + // heat carries the members' assigned channels (no first-fit). + let laps = 1u32; + let registry = fast_registry(laps, 2); + // Retune the Mock to a 2-node cap (channels are the default Raceband 8-wide pool — > node cap). + registry + .timers() + .update( + &TimerId(MOCK_TIMER_ID.to_string()), + &UpdateTimerRequest { + node_count: Some(2), + ..Default::default() + }, + ) + .unwrap(); + + let class_id = registry + .classes() + .create(&CreateClassRequest { + name: "Open".into(), + source: Default::default(), + reference: None, + description: None, + }) + .unwrap() + .id; + let mut pilots = Vec::new(); + for cs in ["a", "b", "c", "d", "e", "f"] { + pilots.push( + registry + .pilots() + .create(&CreatePilotRequest { + callsign: cs.into(), + ..Default::default() + }) + .unwrap() + .id, + ); + } + let token = registry.tokens().issue_rd_token(); + let _bridge = spawn_registry_bridge( + registry.clone(), + SourceConfig::Sim(SimSource::new(laps, Duration::from_millis(2))), + AdapterId(SIM_ADAPTER.to_string()), + ); + let app = build_app(registry.clone(), &no_assets()); + + let (status, body) = call( + &app, + "POST", + "/events", + Some(&token), + Some(serde_json::json!({ "name": "Static Qual" })), + ) + .await; + assert_eq!(status, StatusCode::OK, "create event: {body}"); + let event_meta: serde_json::Value = serde_json::from_str(&body).unwrap(); + let event = EventId(event_meta["id"].as_str().unwrap().to_string()); + let state = registry.resolve(&event).unwrap(); + + control_put( + &app, + &format!("/events/{}/roster", event.0), + &token, + serde_json::json!({ "pilot_ids": pilots.iter().map(|p| p.0.clone()).collect::>() }), + ) + .await; + control_put( + &app, + &format!("/events/{}/classes", event.0), + &token, + serde_json::json!({ "ids": [class_id.0] }), + ) + .await; + + // Membership with per-pilot fixed channels — 3 Raceband channels, two pilots each. + let channels = [5658u16, 5695, 5732]; + let member_slots: Vec = pilots + .iter() + .enumerate() + .map(|(i, p)| serde_json::json!({ "pilot": p.0, "channel": channels[i % 3] })) + .collect(); + control_put( + &app, + &format!("/events/{}/classes/{}/membership", event.0, class_id.0), + &token, + serde_json::json!({ "pilots": member_slots }), + ) + .await; + + // A **static** timed_qual round (1 format-round). + let round: RoundDef = add_round( + &app, + &event, + &token, + NewRoundReq { + label: "Qualifying".into(), + classes: vec![class_id.clone()], + format: "timed_qual".into(), + params: BTreeMap::from([("rounds".into(), "1".into())]), + win_condition: Some(gridfpv_engine::scoring::WinCondition::BestLap), + // Best-lap only ranks; a scored round needs a race time to end (server validation). + time_limit_secs: Some(60), + seeding: SeedingRule::FromRoster, + channel_mode: Some(ChannelMode::Static), + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }, + ) + .await; + control_put( + &app, + "/active-event", + &token, + serde_json::json!({ "id": event.0 }), + ) + .await; + + // Drive the round's channel-balanced heats one at a time to Score until Complete. + for _ in 0..10 { + control_ok( + &app, + &event, + &token, + &Command::FillRound { + round: round.id.clone(), + mode: FillMode::Next, + }, + ) + .await; + // The latest round heat (if a new one was scheduled this FillRound). + let events = read_log(&state); + let scheduled: Vec<&Event> = events + .iter() + .filter(|e| matches!(e, Event::HeatScheduled { round: Some(r), .. } if *r == round.id)) + .collect(); + // Find the newest heat with no terminal transition yet (the one to drive). + let pending = scheduled.iter().rev().find_map(|e| match e { + Event::HeatScheduled { + heat, frequencies, .. + } => { + let scored = events.iter().any(|x| matches!(x, Event::HeatStateChanged { heat: h, transition: gridfpv_events::HeatTransition::Finalized } if h == heat)); + if scored { + None + } else { + Some((heat.clone(), frequencies.clone())) + } + } + _ => None, + }); + let Some((heat, freqs)) = pending else { + break; // Complete — no outstanding heat + }; + // The heat is ≤ the 2-node cap and channel-distinct, carrying the members' fixed channels. + assert!(freqs.len() <= 2, "static heat exceeds node cap: {freqs:?}"); + let mut seen = std::collections::BTreeSet::new(); + for (_, ch) in &freqs { + assert!(seen.insert(*ch), "duplicate channel in a static heat"); + assert!( + channels.contains(ch), + "channel {ch} is a membership channel" + ); + } + let want = freqs.len() * (laps as usize + 1); + control_ok(&app, &event, &token, &Command::Stage { heat: heat.clone() }).await; + // Heat-lifecycle Slice 2: `Start` arms; `SkipCountdown`/`ForceEnd` are the overrides that + // bypass the runtime start/completion clocks so this static-formation flow stays fast. + control_ok(&app, &event, &token, &Command::Start { heat: heat.clone() }).await; + control_ok( + &app, + &event, + &token, + &Command::SkipCountdown { heat: heat.clone() }, + ) + .await; + wait_until(&state, Duration::from_secs(10), move |events| { + passes_in_running_window(events) >= want + }) + .await; + control_ok( + &app, + &event, + &token, + &Command::ForceEnd { heat: heat.clone() }, + ) + .await; + control_ok(&app, &event, &token, &Command::Finalize { heat }).await; + } + + // Every one of the six members flew, across channel-balanced heats of ≤2 distinct channels. + let events = read_log(&state); + let flown: std::collections::BTreeSet = events + .iter() + .filter_map(|e| match e { + Event::HeatScheduled { + lineup, + round: Some(r), + .. + } if *r == round.id => Some(lineup.iter().map(|c| c.0.clone())), + _ => None, + }) + .flatten() + .collect(); + assert_eq!(flown.len(), pilots.len(), "every static member flies"); + // The pool spans all three membership channels (> the 2-node cap). + let used: std::collections::BTreeSet = events + .iter() + .filter_map(|e| match e { + Event::HeatScheduled { + frequencies, + label: None, + round: Some(r), + .. + } if *r == round.id => Some(frequencies.iter().map(|(_, ch)| *ch)), + _ => None, + }) + .flatten() + .collect(); + assert_eq!( + used.len(), + 3, + "channels span the 3-wide pool, beyond the 2-node cap" + ); + // The static MemberSlot wire shape round-tripped (the membership carried channels). + let _ = MemberSlot::new(pilots[0].clone()); +} + +/// A `PUT` JSON request asserted ok (used for class selection / membership / active-event). +async fn control_put(app: &axum::Router, uri: &str, token: &str, body: serde_json::Value) { + let (status, resp) = call(app, "PUT", uri, Some(token), Some(body)).await; + assert_eq!(status, StatusCode::OK, "PUT {uri} failed: {resp}"); +} + +/// Fold the heat's current `HeatState` from a log (the engine's pure fold), for asserting the +/// runtime clock drove the heat to a given state. +fn heat_state_of(events: &[Event], heat: &HeatId) -> Option { + gridfpv_engine::heat::heat_state(events, heat) +} + +/// Build the event + a single round whose **start procedure is near-instant** and whose win +/// condition is `FirstToLaps`, so the runtime clock drives both auto-transitions quickly. Returns +/// `(registry, app, token, event, round_id, pilots)`. +async fn fast_auto_event( + laps: u32, + lap_ms: u64, + win_laps: u32, +) -> ( + EventRegistry, + axum::Router, + String, + EventId, + RoundId, + Vec, +) { + let registry = fast_registry(laps, lap_ms); + let class_id = registry + .classes() + .create(&CreateClassRequest { + name: "Open".into(), + source: Default::default(), + reference: None, + description: None, + }) + .unwrap() + .id; + let mut pilots = Vec::new(); + for cs in ["alpha", "bravo"] { + pilots.push( + registry + .pilots() + .create(&CreatePilotRequest { + callsign: cs.into(), + ..Default::default() + }) + .unwrap() + .id, + ); + } + let token = registry.tokens().issue_rd_token(); + let _bridge = spawn_registry_bridge( + registry.clone(), + SourceConfig::Sim(SimSource::new(laps, Duration::from_millis(lap_ms))), + AdapterId(SIM_ADAPTER.to_string()), + ); + // Leak the bridge handle so it runs for the whole test (the registry keeps the logs alive). + std::mem::forget(_bridge); + let app = build_app(registry.clone(), &no_assets()); + + let (status, body) = call( + &app, + "POST", + "/events", + Some(&token), + Some(serde_json::json!({ "name": "Auto Clock" })), + ) + .await; + assert_eq!(status, StatusCode::OK, "create event: {body}"); + let event_meta: serde_json::Value = serde_json::from_str(&body).unwrap(); + let event = EventId(event_meta["id"].as_str().unwrap().to_string()); + + control_put( + &app, + &format!("/events/{}/roster", event.0), + &token, + serde_json::json!({ "pilot_ids": pilots.iter().map(|p| p.0.clone()).collect::>() }), + ) + .await; + control_put( + &app, + &format!("/events/{}/classes", event.0), + &token, + serde_json::json!({ "ids": [class_id.0] }), + ) + .await; + control_put( + &app, + &format!("/events/{}/classes/{}/membership", event.0, class_id.0), + &token, + serde_json::json!({ "pilots": pilots.iter().map(|p| p.0.clone()).collect::>() }), + ) + .await; + + let round: RoundDef = add_round( + &app, + &event, + &token, + NewRoundReq { + label: "Qualifying".into(), + classes: vec![class_id.clone()], + format: "timed_qual".into(), + params: BTreeMap::from([("rounds".into(), "1".into())]), + // FirstToLaps gives the completion clock an intrinsic end (the leader reaching `win_laps`). + win_condition: Some(gridfpv_engine::scoring::WinCondition::FirstToLaps { n: win_laps }), + time_limit_secs: None, + seeding: SeedingRule::FromRoster, + channel_mode: Some(ChannelMode::PerHeat), + staging_timer_secs: None, + // A near-instant start hold so the auto Armed→Running fires within a poll. + start_procedure: Some(StartProcedure::RandomizedDelay { + min_delay_ms: 0, + max_delay_ms: 1, + tone: None, + }), + // A short bounded grace so the auto Running→Unofficial fires promptly after the win. + grace_window: Some(GraceWindow::Duration { micros: 5_000 }), + protest_window: None, + min_lap_secs: None, + }, + ) + .await; + control_put( + &app, + "/active-event", + &token, + serde_json::json!({ "id": event.0 }), + ) + .await; + (registry, app, token, event, round.id, pilots) +} + +/// The headline auto-advance: after `Start` (which arms the heat) the **runtime clock** drives the +/// heat all the way to `Unofficial` on its own — auto `Armed → Running` (after the logged start +/// delay) and auto `Running → Unofficial` (on the win condition + grace) — with no manual +/// `SkipCountdown`/`ForceEnd`. Then `Finalize` reaches `Final`. +#[tokio::test] +async fn heat_auto_advances_running_to_unofficial_under_the_clock() { + let (registry, app, token, event, round, pilots) = fast_auto_event(5, 2, 3).await; + let state = registry.resolve(&event).unwrap(); + + control_ok( + &app, + &event, + &token, + &Command::FillRound { + round: round.clone(), + mode: FillMode::Next, + }, + ) + .await; + let (heat, _class, _lineup) = + round_heat(&read_log(&state), &round.0).expect("a heat scheduled"); + + // Stage, then Start — Start ARMS the heat; the runtime then auto-advances. No SkipCountdown. + control_ok(&app, &event, &token, &Command::Stage { heat: heat.clone() }).await; + control_ok(&app, &event, &token, &Command::Start { heat: heat.clone() }).await; + + // The runtime logs the start delay (`HeatStarting`), then auto-appends `Running`. + let starting_heat = heat.clone(); + wait_until(&state, Duration::from_secs(10), move |events| { + events + .iter() + .any(|e| matches!(e, Event::HeatStarting { heat: h, .. } if *h == starting_heat)) + }) + .await; + + // The completion clock then auto-advances `Running → Unofficial` once the leader hits 3 laps + + // the grace window — no `ForceEnd` was sent. + let unofficial_heat = heat.clone(); + wait_until(&state, Duration::from_secs(15), move |events| { + heat_state_of(events, &unofficial_heat) == Some(gridfpv_engine::heat::HeatState::Unofficial) + }) + .await; + + // Exactly one auto `Running` and one auto `Finished` were appended (the runtime drove them). + let events = read_log(&state); + let running = events + .iter() + .filter(|e| matches!(e, Event::HeatStateChanged { heat: h, transition: gridfpv_events::HeatTransition::Running } if *h == heat)) + .count(); + let finished = events + .iter() + .filter(|e| matches!(e, Event::HeatStateChanged { heat: h, transition: gridfpv_events::HeatTransition::Finished } if *h == heat)) + .count(); + assert_eq!(running, 1, "the runtime auto-appended exactly one Running"); + assert_eq!( + finished, 1, + "the runtime auto-appended exactly one Finished" + ); + + // Finalize closes the loop. + control_ok( + &app, + &event, + &token, + &Command::Finalize { heat: heat.clone() }, + ) + .await; + assert_eq!( + heat_state_of(&read_log(&state), &heat), + Some(gridfpv_engine::heat::HeatState::Final) + ); + assert_eq!(pilots.len(), 2); +} + +/// **Determinism / replay** (the headline guarantee): drive a heat through the full auto-clock path +/// to `Final`, then **replay the resulting log** through the pure engine fold and assert the replay +/// reproduces the identical state — the logged start delay (`HeatStarting`) + the two +/// runtime-appended auto-transitions are *facts* the fold reads, never re-randomized or recomputed +/// from a clock (race-engine.html §6). +#[tokio::test] +async fn the_logged_clock_events_replay_deterministically() { + let (registry, app, token, event, round, _pilots) = fast_auto_event(5, 2, 3).await; + let state = registry.resolve(&event).unwrap(); + + control_ok( + &app, + &event, + &token, + &Command::FillRound { + round: round.clone(), + mode: FillMode::Next, + }, + ) + .await; + let (heat, _class, _lineup) = + round_heat(&read_log(&state), &round.0).expect("a heat scheduled"); + control_ok(&app, &event, &token, &Command::Stage { heat: heat.clone() }).await; + control_ok(&app, &event, &token, &Command::Start { heat: heat.clone() }).await; + + // Wait for the full auto path to land, then Finalize. + let wheat = heat.clone(); + wait_until(&state, Duration::from_secs(15), move |events| { + heat_state_of(events, &wheat) == Some(gridfpv_engine::heat::HeatState::Unofficial) + }) + .await; + control_ok( + &app, + &event, + &token, + &Command::Finalize { heat: heat.clone() }, + ) + .await; + + let log = read_log(&state); + + // (1) The start delay was logged once as a FACT — the runtime chose it, the fold never re-rolls. + let delays: Vec = log + .iter() + .filter_map(|e| match e { + Event::HeatStarting { heat: h, delay_ms } if *h == heat => Some(*delay_ms), + _ => None, + }) + .collect(); + assert_eq!(delays.len(), 1, "exactly one logged start delay"); + + // (2) Replay: fold the SAME log twice; a pure fold gives the same state every time, with no + // hidden clock/RNG re-evaluation. This is the deterministic-replay guarantee. + let first = heat_state_of(&log, &heat); + let second = heat_state_of(&log, &heat); + assert_eq!(first, second); + assert_eq!(first, Some(gridfpv_engine::heat::HeatState::Final)); + + // (3) Replaying the start delay reads the SAME value — no re-randomization on replay. + let replay_delays: Vec = log + .iter() + .filter_map(|e| match e { + Event::HeatStarting { heat: h, delay_ms } if *h == heat => Some(*delay_ms), + _ => None, + }) + .collect(); + assert_eq!( + replay_delays, delays, + "the logged delay is stable on replay" + ); + + // (4) The auto-transitions appear exactly once each, in canonical forward order — the same log + // a fresh Director would replay identically. + let transitions: Vec = log + .iter() + .filter_map(|e| match e { + Event::HeatStateChanged { + heat: h, + transition, + } if *h == heat => Some(*transition), + _ => None, + }) + .collect(); + assert_eq!( + transitions, + vec![ + gridfpv_events::HeatTransition::Staged, + gridfpv_events::HeatTransition::Armed, + gridfpv_events::HeatTransition::Running, + gridfpv_events::HeatTransition::Finished, + gridfpv_events::HeatTransition::Finalized, + ], + "the runtime-driven log folds the full forward path, once each" + ); +} + +/// Open-practice refinement e2e: creating an **open-practice** round auto-creates its single channel +/// heat (no manual `FillRound`), idempotently (re-creating doesn't duplicate it), and a short +/// `time_limit_secs` auto-ends the running practice (Running → Unofficial) with no `ForceEnd`. +#[tokio::test] +async fn open_practice_round_auto_creates_heat_and_time_limit_auto_ends_it_e2e() { + let registry = fast_registry(3, 1); + let token = registry.tokens().issue_rd_token(); + let _bridge = spawn_registry_bridge( + registry.clone(), + SourceConfig::Sim(SimSource::new(3, Duration::from_millis(1))), + AdapterId(SIM_ADAPTER.to_string()), + ); + std::mem::forget(_bridge); + let app = build_app(registry.clone(), &no_assets()); + + let (status, body) = call( + &app, + "POST", + "/events", + Some(&token), + Some(serde_json::json!({ "name": "Practice" })), + ) + .await; + assert_eq!(status, StatusCode::OK, "create event: {body}"); + let event_meta: serde_json::Value = serde_json::from_str(&body).unwrap(); + let event = EventId(event_meta["id"].as_str().unwrap().to_string()); + + // Define an open-practice round: NO win condition, a 1s time limit, AllChannels seeding over two + // node-seats. Creating it must auto-build the single open heat (the channels are the lineup). + let open_req = || NewRoundReq { + label: "Open Practice".into(), + classes: vec![], + format: "open_practice".into(), + params: BTreeMap::new(), + win_condition: None, + time_limit_secs: Some(1), + seeding: SeedingRule::AllChannels { + channels: vec![0, 1], + }, + channel_mode: None, + staging_timer_secs: None, + start_procedure: Some(StartProcedure::RandomizedDelay { + min_delay_ms: 0, + max_delay_ms: 1, + tone: None, + }), + grace_window: None, + protest_window: None, + min_lap_secs: None, + }; + let round: RoundDef = add_round(&app, &event, &token, open_req()).await; + let state = registry.resolve(&event).unwrap(); + + // (a) The heat was auto-created: exactly one HeatScheduled tagged with the round, no FillRound. + let scheduled_for_round = |events: &[Event]| { + events + .iter() + .filter(|e| matches!(e, Event::HeatScheduled { round: Some(r), .. } if *r == round.id)) + .count() + }; + assert_eq!( + scheduled_for_round(&read_log(&state)), + 1, + "creating an open-practice round auto-creates exactly one heat" + ); + + // (b) Idempotent: re-running the auto-fill (the same round's FillRound) appends no second heat. + control_ok( + &app, + &event, + &token, + &Command::FillRound { + round: round.id.clone(), + mode: FillMode::Next, + }, + ) + .await; + assert_eq!( + scheduled_for_round(&read_log(&state)), + 1, + "a re-fill of the open-practice round does not duplicate its heat" + ); + + let (heat, _class, _lineup) = + round_heat(&read_log(&state), &round.id.0).expect("the auto-created open-practice heat"); + + // (c) The time limit auto-ends the running practice. Make active, Stage, Start — then the runtime + // drives Armed → Running (instant start hold) and, ~1s later, the time-limit Running → Unofficial. + control_put( + &app, + "/active-event", + &token, + serde_json::json!({ "id": event.0 }), + ) + .await; + control_ok(&app, &event, &token, &Command::Stage { heat: heat.clone() }).await; + control_ok(&app, &event, &token, &Command::Start { heat: heat.clone() }).await; + + let target = heat.clone(); + wait_until(&state, Duration::from_secs(6), move |events| { + heat_state_of(events, &target) == Some(gridfpv_engine::heat::HeatState::Unofficial) + }) + .await; + + // Exactly one auto Finished (the Running → Unofficial step) was appended by the time-limit driver. + let events = read_log(&state); + let finished = events + .iter() + .filter(|e| matches!(e, Event::HeatStateChanged { heat: h, transition: gridfpv_events::HeatTransition::Finished } if *h == heat)) + .count(); + assert_eq!( + finished, 1, + "the time limit auto-ends the practice exactly once" + ); + + // (d) Live-state desync fix — the **served** live state's phase/clock are the REAL log's at every + // step, with the per-channel laps spliced on top (laps-only overlay). The console renders phase → + // buttons and the clock from this served state, so it must follow the log exactly — never a stale + // synthetic phase. We fetch the served heat snapshot (the same merge the `/stream` fold applies). + // + // The time limit has driven the LOG to `Unofficial` (asserted above). The served live state must + // therefore read `Unofficial` (so the console clock freezes at the duration — no synthetic-start + // bump), while the non-logged per-channel laps stay visible (the accumulator holds them through + // `Unofficial`; only a true terminal / abort / restart clears them). + let served_live = |heat: &HeatId| { + let app = app.clone(); + let event = event.clone(); + let token = token.clone(); + let heat = heat.clone(); + async move { + let (status, body) = call( + &app, + "GET", + &format!("/events/{}/snapshot/heat/{}", event.0, heat.0), + Some(&token), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "served heat snapshot: {body}"); + let snap: gridfpv_server::snapshot::Snapshot = serde_json::from_str(&body).unwrap(); + match snap.body { + gridfpv_server::snapshot::ProjectionBody::LiveRaceState(ls) => ls, + other => panic!("expected a LiveRaceState body, got {other:?}"), + } + } + }; + + let unofficial = served_live(&heat).await; + assert_eq!( + unofficial.phase, + gridfpv_server::snapshot::HeatPhase::Unofficial, + "the served phase follows the log to Unofficial so the console clock freezes (no bump)" + ); + assert_eq!(unofficial.current_heat.as_ref(), Some(&heat)); + // The per-channel laps are still carried through Unofficial, each row unbound (per channel). + assert!( + !unofficial.progress.is_empty(), + "the per-channel laps stay visible through Unofficial" + ); + assert!( + unofficial.progress.iter().all(|p| p.pilot.is_none()), + "open-practice rows stay unbound (per channel)" + ); + + // (e) Restart → Scheduled: the RD restarts the closed practice. The engine resets the heat to + // `Scheduled` (a full reset, like Abort; the RD re-Stages); the bridge clears the accumulator + // (wake-on-clear). The served live state must then read **`Scheduled`** with the laps cleared — + // never a stale `Unofficial` (the bug: the console kept rendering `Unofficial`/Final buttons and + // `Restart` then errored "illegal … in state Staged"). + control_ok( + &app, + &event, + &token, + &Command::Restart { heat: heat.clone() }, + ) + .await; + wait_until(&state, Duration::from_secs(3), { + let heat = heat.clone(); + move |events| { + heat_state_of(events, &heat) == Some(gridfpv_engine::heat::HeatState::Scheduled) + } + }) + .await; + + // The accumulator clears one bridge poll after it observes the restart; wait for it to settle. + let practice_overlay = registry.resolve(&event).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(3); + while practice_overlay.open_practice().is_active(&heat) { + assert!( + std::time::Instant::now() < deadline, + "the open-practice accumulator never cleared after Restart" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + + let scheduled = served_live(&heat).await; + assert_eq!( + scheduled.phase, + gridfpv_server::snapshot::HeatPhase::Scheduled, + "after Restart the served phase is the log's Scheduled — never a stale Unofficial" + ); + assert!( + scheduled.progress.iter().all(|p| p.laps_completed == 0), + "the per-channel laps are cleared after Restart" + ); + + // The clock-timing basis is the log's at every step: the heat carries exactly one `Finished` + // (the time-limit close) and one `Restarted`, so the served phase moved Running → Unofficial → + // Scheduled with no synthetic re-`Running` that would reset/bump the console clock. + let log = read_log(&state); + let restarts = log + .iter() + .filter(|e| matches!(e, Event::HeatStateChanged { heat: h, transition: gridfpv_events::HeatTransition::Restarted } if *h == heat)) + .count(); + assert_eq!( + restarts, 1, + "exactly one Restarted resets the heat to Scheduled" + ); +} + +#[tokio::test] +async fn two_open_practice_rounds_in_one_event_get_distinct_heats_e2e() { + // Issue #54: two open-practice rounds in the same event must each auto-create their OWN heat. + // Before the fix both rounds' heats collided on the generator's fixed `"open-practice"` id, so + // the second round silently got no distinct heat. The heat id is now round-scoped. + let registry = fast_registry(3, 1); + let token = registry.tokens().issue_rd_token(); + let _bridge = spawn_registry_bridge( + registry.clone(), + SourceConfig::Sim(SimSource::new(3, Duration::from_millis(1))), + AdapterId(SIM_ADAPTER.to_string()), + ); + std::mem::forget(_bridge); + let app = build_app(registry.clone(), &no_assets()); + + let (status, body) = call( + &app, + "POST", + "/events", + Some(&token), + Some(serde_json::json!({ "name": "Two Practices" })), + ) + .await; + assert_eq!(status, StatusCode::OK, "create event: {body}"); + let event_meta: serde_json::Value = serde_json::from_str(&body).unwrap(); + let event = EventId(event_meta["id"].as_str().unwrap().to_string()); + + let open_req = |label: &str| NewRoundReq { + label: label.into(), + classes: vec![], + format: "open_practice".into(), + params: BTreeMap::new(), + win_condition: None, + time_limit_secs: None, + seeding: SeedingRule::AllChannels { + channels: vec![0, 1], + }, + channel_mode: None, + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }; + + let round_a: RoundDef = add_round(&app, &event, &token, open_req("Open A")).await; + let round_b: RoundDef = add_round(&app, &event, &token, open_req("Open B")).await; + let state = registry.resolve(&event).unwrap(); + + // Each round auto-created exactly one heat, and the two heat ids are distinct + round-scoped. + let (heat_a, _, _) = round_heat(&read_log(&state), &round_a.id.0) + .expect("round A auto-created its open-practice heat"); + let (heat_b, _, _) = round_heat(&read_log(&state), &round_b.id.0) + .expect("round B auto-created its own open-practice heat"); + assert_ne!( + heat_a, heat_b, + "two open-practice rounds must not share one heat id (#54)" + ); + assert_eq!(heat_a.0, format!("{}-heat", round_a.id.0)); + assert_eq!(heat_b.0, format!("{}-heat", round_b.id.0)); +} + +/// `POST …/rounds` asserted ok, returning the created [`RoundDef`]. +async fn add_round(app: &axum::Router, event: &EventId, token: &str, req: NewRoundReq) -> RoundDef { + let (status, body) = call( + app, + "POST", + &format!("/events/{}/rounds", event.0), + Some(token), + Some(serde_json::to_value(&req).unwrap()), + ) + .await; + assert_eq!(status, StatusCode::OK, "add round failed: {body}"); + serde_json::from_str(&body).unwrap() +} diff --git a/crates/app/tests/replay.rs b/crates/app/tests/replay.rs index e9fad3b..62eb3b2 100644 --- a/crates/app/tests/replay.rs +++ b/crates/app/tests/replay.rs @@ -34,14 +34,34 @@ fn sprint_session_matches_golden() { ); } -/// The fold is deterministic and order-independent: shuffling the recorded events -/// yields the identical lap list (passes are grouped and ordered by the engine). +/// The fold is deterministic and order-independent in its **lap shape**: shuffling the recorded +/// events yields the identical per-competitor lap *durations* (passes are grouped and ordered by +/// the engine on `at`/`sequence`, not log position). +/// +/// The laps' `start_ref`/`end_ref` are append *offsets* — they legitimately track each pass's +/// position in the log, so reversing the log re-indexes them (a UI never sees a reversed log; the +/// offsets are stable for a given append order). So we compare the order-independent lap shape +/// `(competitor, number, duration)` rather than the offset-bearing whole. #[test] fn replay_is_recomputable_regardless_of_event_order() { let session = include_str!("fixtures/sprint.events.json"); let mut events: Vec = serde_json::from_str(session).unwrap(); + let shape = |list: &gridfpv_projection::LapList| { + list.competitors + .iter() + .map(|c| { + ( + c.competitor.clone(), + c.laps + .iter() + .map(|l| (l.number, l.duration_micros)) + .collect::>(), + ) + }) + .collect::>() + }; let forward = lap_list(&events); events.reverse(); let reversed = lap_list(&events); - assert_eq!(forward, reversed); + assert_eq!(shape(&forward), shape(&reversed)); } diff --git a/crates/app/tests/rh_connect_live.rs b/crates/app/tests/rh_connect_live.rs new file mode 100644 index 0000000..87cbaf5 --- /dev/null +++ b/crates/app/tests/rh_connect_live.rs @@ -0,0 +1,650 @@ +//! Dockerized-RotorHazard **Director persistent-connect** e2e (#65, #73, #105). +//! +//! The end-to-end proof that the Director **connects a real RotorHazard on selection and keeps it +//! connected** (#105) — the live counterpart to the in-process Mock-bridge tests in `src/source.rs`. +//! It: +//! +//! 1. Spins up a disposable dockerized RotorHazard (mock node) via the shared +//! [`RhContainer`](gridfpv_testkit::RhContainer) harness. +//! 2. Builds an [`EventRegistry`], creates a [`Rotorhazard { url }`] timer pointed at the +//! container, **makes Practice the active event**, and **selects the RH timer for Practice** (the +//! selection the persistent-connection reconciler reads). +//! 3. Spawns the **real** registry bridge ([`spawn_registry_bridge`]) — the same wiring `main` +//! runs (it also spawns the persistent-connection reconciler). +//! 4. Asserts the Director **connects on selection, before any heat**: the timer's [`TimerStatus`] +//! advances to `Connected` *without* a heat being run (#105 — the whole point: a drop-off is +//! visible before/between races). +//! 5. Then drives Practice's heat through the real lifecycle to `Running`, and asserts **passes +//! flow** into Practice's log over that already-live connection (real RH crossings, attributed to +//! the heat's lineup). This also exercises the **Grid-owns-all-timing** flow: at **Staged** the +//! bridge prepares the RH connection (zero RH's staging hold/tones + reset to READY) **and seats +//! the heat's bound pilots onto their RH nodes** (the laps-attribute fix), and at **Running** +//! (Grid's go) the driver emits a single `stage_race` so RH starts recording immediately — no +//! RH-side staging sequence competing with Grid's start procedure. Because the reset now happens +//! at Staged (seconds before the start emit, never the same gevent tick), the old reset-vs-staging +//! race is gone and the `STAGE_RESET_SETTLE` band-aid is retired; this assertion (passes land, the +//! heat is not zero-laps) is the guard. The seating is asserted directly: RH's own +//! "Racing heat … pilots: Ace" log lists the bound callsign (vs the empty pilots list of the +//! unseated bug), and RH dismisses **no** crossing for an unseated node. +//! 6. Finishes the heat and asserts the connection **stays `Connected`** — the heat is disarmed but +//! the persistent connection is NOT torn down (#105), so status keeps reflecting the live link. +//! 7. **Stops the RH container** out from under the live connection and asserts the Director +//! **detects the drop** — the timer leaves `Connected` (→ `Disconnected`/`Error`/`Connecting`) +//! within ~10s (#105). This is the regression guard: a buffering auto-reconnect used to hide a +//! real drop, so the timer read `Connected` indefinitely. +//! +//! Like every `*_live` test this is **structural / tolerant** — RH's mock interface reads its CSV +//! continuously so lap *timing* is not controllable; we assert the connection state reached and +//! the presence of passes, never exact µs or an exact lap count. +//! +//! Local-only class (needs Docker), gated behind `--features live` + `#[ignore]`. DISTINCT RH port +//! 5042 (server full-event uses 5041, engine full-event 5040). Run via `cargo xtask live`, or: +//! +//! ```sh +//! cargo test -p gridfpv-app --features live --test rh_connect_live -- --ignored --nocapture +//! ``` +#![cfg(feature = "live")] + +use std::time::{Duration, Instant}; + +use gridfpv_app::source::{SIM_ADAPTER, SourceConfig, spawn_registry_bridge}; +use gridfpv_events::{AdapterId, CompetitorRef, Event, HeatId, HeatTransition}; +use gridfpv_server::app::AppState; +use gridfpv_server::events::{EventRegistry, PRACTICE_EVENT_ID}; +use gridfpv_server::scope::EventId; +use gridfpv_server::timers::{ + CreateTimerRequest, MOCK_TIMER_ID, PluginPresence, TimerId, TimerKind, TimerStatus, +}; +use gridfpv_testkit::{NodeCsv, RhContainer, node_csv}; + +/// Count how many lines of `rh`'s container log contain `needle` — used to assert the heat-end dense +/// save fires **exactly once** (no #250 loop: `Heat added` / `Current laps saved` must not repeat). +fn count_log_lines(rh: &RhContainer, needle: &str) -> usize { + let out = std::process::Command::new("docker") + .args(["logs", rh.name()]) + .output() + .expect("docker logs"); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + text.lines().filter(|l| l.contains(needle)).count() +} + +/// DISTINCT RH host port for the app's RH-connect e2e (server full-event 5041, engine 5040). +const RH_PORT: u16 = 5042; +/// CSV tick interval (seconds) — a brisk pace so passes land within the live window. +const TICK: &str = "0.1"; + +/// Practice's event id (its in-memory log + default selection the bridge drives). +fn practice() -> EventId { + EventId(PRACTICE_EVENT_ID.to_string()) +} + +fn count_passes(events: &[Event]) -> usize { + events + .iter() + .filter(|e| matches!(e, Event::Pass(p) if p.gate.is_lap_gate())) + .count() +} + +fn read_all(state: &AppState) -> Vec { + state + .log() + .lock() + .unwrap() + .read_all() + .unwrap() + .into_iter() + .map(|s| s.event) + .collect() +} + +/// Poll until `cond` holds or `deadline` elapses; returns whether it held. +async fn wait_for(deadline: Duration, mut cond: impl FnMut() -> bool) -> bool { + let start = Instant::now(); + loop { + if cond() { + return true; + } + if start.elapsed() > deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(200)).await; + } +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires Docker (spins up dockerized RotorHazard, connects it on selection, then drives a live heat over the persistent connection)"] +async fn director_connects_rotorhazard_on_selection_and_keeps_it_connected_through_a_heat() { + // One busy node so several real passes land while the heat runs. `node-0` is the seat the + // adapter reports; the bridge remaps it onto the heat's first lineup slot. + let scenario = vec![( + 0usize, + node_csv(&NodeCsv { + ticks_per_lap: 2, + peak_rssi: 180, + baseline_rssi: 70, + seed: 0, + }), + )]; + // RAII: the container is removed when `rh` drops at the end of the test. + let rh = RhContainer::start(RH_PORT, TICK, &scenario); + + // === Build the registry, configure an RH timer, and select it for Practice. === + let registry = EventRegistry::new(None).expect("event registry"); + let rh_timer = registry + .timers() + .create(&CreateTimerRequest { + name: "Field RH".into(), + kind: TimerKind::Rotorhazard { + url: rh.url().to_string(), + }, + channel_capability: None, + node_count: None, + available_channels: None, + }) + .expect("create RH timer"); + // A freshly-configured RH timer rests at `Configured` until the Director connects it. + assert_eq!( + registry.timers().get(&rh_timer.id).unwrap().status, + TimerStatus::Configured + ); + // Make Practice the active event and select the RH timer for it — the reconciler connects the + // active event's selected RH timers (#105). + registry + .set_active(&practice()) + .expect("make Practice active"); + registry + .set_timers(&practice(), vec![rh_timer.id.clone()]) + .expect("select the RH timer for Practice"); + + let state = registry.resolve(&practice()).expect("Practice state"); + + // === Spawn the REAL registry bridge — the same wiring `main` runs (it also spawns the + // persistent-connection reconciler). === + let _bridge = spawn_registry_bridge( + registry.clone(), + SourceConfig::from_env(), + AdapterId(SIM_ADAPTER.to_string()), + ); + + // === The Director connects ON SELECTION — BEFORE any heat is run (#105). This is the crux: + // a selected-but-idle RH timer must reach Connected so a drop-off is visible before/between + // races, not only while a race is underway. === + let timers = registry.timers(); + let id = rh_timer.id.clone(); + let connected = wait_for(Duration::from_secs(30), || { + timers.get(&id).map(|t| t.status) == Some(TimerStatus::Connected) + }) + .await; + assert!( + connected, + "the Director should report the RH timer Connected on selection, before any heat; status = {:?}", + timers.get(&rh_timer.id).map(|t| t.status) + ); + // The GridFPV-plugin handshake probe runs right after connect (D16, S1): the timer's plugin + // presence becomes *known* (Some). Under `cargo xtask live` the plugin folder is mounted + // (GRIDFPV_RH_PLUGIN), so it resolves to `Present`; a plain `cargo test` (no plugin mounted) + // resolves to `Missing`. Assert it was probed, and — when mounted — that it's recognized. + let probed = wait_for(Duration::from_secs(10), || { + timers.get(&id).map(|t| t.plugin.is_some()).unwrap_or(false) + }) + .await; + assert!( + probed, + "the Director should probe the RH timer for the GridFPV plugin after connect; plugin = {:?}", + timers.get(&id).map(|t| t.plugin.clone()) + ); + if std::env::var_os("GRIDFPV_RH_PLUGIN").is_some() { + let plugin = timers.get(&id).and_then(|t| t.plugin.clone()); + assert!( + matches!(plugin, Some(PluginPresence::Present { .. })), + "with the GridFPV plugin mounted, the handshake should report it Present; got {plugin:?}" + ); + } + + // No heat has run yet, yet the timer is live — there are no passes in the log at this point. + assert_eq!( + count_passes(&read_all(&state)), + 0, + "no passes should exist before a heat — the connection is idle but live" + ); + + // === Now drive Practice's heat through the real lifecycle to Running (what the control path + // appends: Scheduled → Staged → Armed → Running). It uses the ALREADY-LIVE connection rather than + // dialing a fresh socket. The **Staged** step is load-bearing for the Grid-owns-timing flow: it + // is where the bridge *prepares* the RH connection (zero its staging hold/tones + reset to READY) + // so the eventual arm at Running starts RH recording instantly with no RH-side staging. === + let heat = HeatId("q-rh-1".into()); + let pilot = CompetitorRef("Ace".into()); + state + .append( + Event::HeatScheduled { + heat: heat.clone(), + lineup: vec![pilot.clone()], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + for transition in [ + HeatTransition::Staged, + HeatTransition::Armed, + HeatTransition::Running, + ] { + state + .append( + Event::HeatStateChanged { + heat: heat.clone(), + transition, + }, + None, + ) + .unwrap(); + // Give the bridge a poll to act on Staged (prepare the RH connection) before arming at + // Running — mirrors the real control path's spacing (the Armed hold sits between them). + tokio::time::sleep(Duration::from_millis(700)).await; + } + + // === Passes flow into Practice's log (real RH crossings, attributed to the lineup). === + let got_passes = wait_for(Duration::from_secs(40), || { + count_passes(&read_all(&state)) >= 1 + }) + .await; + assert!( + got_passes, + "real RotorHazard passes should land in the event log while the heat is Running" + ); + // The passes are attributed to the heat's actual competitor (node-0 remapped onto lineup[0]), + // not the raw `node-0` seat handle. + let events = read_all(&state); + assert!( + events + .iter() + .any(|e| matches!(e, Event::Pass(p) if p.competitor == pilot)), + "passes should be remapped onto the heat's lineup competitor" + ); + let pass_count = count_passes(&events); + + // === The laps-attribute fix: the heat's bound pilot is SEATED on its RH node at Stage, so RH + // records AND attributes passes (its pass gate dismisses a crossing on a node with no seated + // pilot — the zero-laps bug). Prove the seat took on the RH side two ways: + // + // 1. RH's own staging log names the seated callsign — "Racing heat '…' round N, pilots: Ace" — + // rather than the empty "pilots:" that the unseated (buggy) path logged. This is the direct + // before/after: empty pilots list → the bound callsign listed. + let pilots_line_lists_the_seated_callsign = { + let out = std::process::Command::new("docker") + .args(["logs", rh.name()]) + .output() + .expect("docker logs"); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + text.lines() + .filter(|l| l.contains("Racing heat") && l.contains("pilots:")) + .any(|l| l.contains("Ace")) + }; + assert!( + pilots_line_lists_the_seated_callsign, + "RotorHazard's 'Racing heat … pilots:' log must list the heat's bound pilot ('Ace') — the \ + laps-attribute fix seats it on the RH node at Stage; an empty pilots list is the unseated \ + bug that records zero laps" + ); + // 2. RH actually RECORDED passes (logged a `Pass record`, not the `Pass record dismissed: … + // Pilot not defined` the unseated gate emits). The `got_passes` assertion above already + // proved the laps flowed into the event log (attributed to node-0 → lineup[0]); this + // re-affirms RH itself recorded them rather than dismissing every crossing. + assert_eq!( + count_log_lines(&rh, "Pilot not defined"), + 0, + "RotorHazard must not DISMISS passes for an unseated node ('Pilot not defined') — the bound \ + pilot is seated, so every crossing on its node records" + ); + + // Let the heat run on a little so the COARSE streamed trace accumulates a representative run of + // samples (one `SignalChunk` per `node_data` heartbeat) — a longer, more realistic baseline for + // the dense path to beat than a single sample. + let pilot_key = gridfpv_projection::CompetitorKey { + adapter: AdapterId(SIM_ADAPTER.to_string()), + competitor: pilot.clone(), + }; + let _ = wait_for(Duration::from_secs(12), || { + gridfpv_projection::signal_trace(&read_all(&state)) + .competitor(&pilot_key) + .map(|t| t.samples.len()) + .unwrap_or(0) + >= 30 + }) + .await; + let events = read_all(&state); + // The COARSE streamed sample count for the lineup pilot, captured live before the heat finishes. + let coarse_samples = gridfpv_projection::signal_trace(&events) + .competitor(&pilot_key) + .map(|t| t.samples.len()) + .unwrap_or(0); + assert!( + coarse_samples >= 1, + "the live heat should have streamed at least one coarse signal sample" + ); + + // === Finish the heat: the heat is disarmed but the persistent connection STAYS UP (#105). === + state + .append( + Event::HeatStateChanged { + heat, + transition: HeatTransition::Finished, + }, + None, + ) + .unwrap(); + + // === Marshaling path-2 through the PRODUCTION flow: finishing the heat disarms it, which makes + // the driver stop the RH race (-> DONE) and pull RotorHazard's DENSE per-tick history into the + // finishing heat's log. Assert a `SignalHistory` lands and the folded trace now carries strictly + // MORE samples than the coarse stream — the full-fidelity upgrade, activated by the normal + // staging/finish loop. The savable heat is the one **seated at Stage** (already current), which + // the finish reuses rather than adding a separate empty heat — not a bespoke marshal-data poke. === + let got_dense = wait_for(Duration::from_secs(20), || { + read_all(&state) + .iter() + .any(|e| matches!(e, Event::SignalHistory(_))) + }) + .await; + assert!( + got_dense, + "the production heat-finish flow must pull RotorHazard's dense SignalHistory into the log \ + (coarse stream was {coarse_samples} samples)" + ); + let dense_events = read_all(&state); + let dense_samples = gridfpv_projection::signal_trace(&dense_events) + .competitor(&pilot_key) + .map(|t| t.samples.len()) + .expect("dense trace for the lineup pilot"); + // S2 split: with the GridFPV plugin (the path `cargo xtask live` mounts) the dense history is + // pushed LIVE over `gridfpv_signal` and supersedes the coarse stream *during* the race — the + // post-race pull is suppressed, so the mid-race "coarse" baseline is already the dense trace and + // `dense == coarse` is expected. Without the plugin (stock RH fallback, deleted in S3) the + // DONE-edge save-then-pull yields strictly more samples than the coarse stream. + if std::env::var_os("GRIDFPV_RH_PLUGIN").is_some() { + assert!( + dense_samples >= 1, + "the live plugin must deliver a dense SignalHistory trace; got {dense_samples}" + ); + } else { + assert!( + dense_samples > coarse_samples, + "the dense history must supersede the coarse stream with MORE samples: \ + dense={dense_samples} coarse={coarse_samples}" + ); + } + eprintln!( + "app marshaling path-2 (production flow): coarse stream = {coarse_samples} samples, dense \ + history = {dense_samples} samples (full-fidelity upgrade activated by the normal heat loop)" + ); + + // === #250 regression guard: the heat-end dense save must fire EXACTLY ONCE. === + // + // The #250 dense activation re-fired the heat-end `add_heat → set_current_heat → stop_race` + // dance on every `maintain` re-entry: the burst of emits could drop the socket, and the driver + // reconnected into a fresh `maintain` whose local guard was reset while the *shared* armed slot + // was still `finishing` — so it re-ran the dance, looping heat after heat, re-flooding+resetting + // the link and stopping the live race so NO laps landed. The fix moves the once-only guard into + // the shared slot (`done`, set before any emit), so a reconnect/re-sent DONE/maintain re-entry + // never re-runs it. We prove that here three ways: + // + // 1. The RH container log shows the heat-save dance ran ONCE, not in a loop. RotorHazard logs + // `Current laps saved: ...` per `save_laps`; a loop logs it many times. We allow up to a + // small constant for the legitimate single save (and the per-pilotrace pull on older RH), + // but a loop would show double-digits. `Heat added` likewise must not repeat per finish. + let laps_saved = count_log_lines(&rh, "Current laps saved"); + let heats_added = count_log_lines(&rh, "Heat added"); + eprintln!( + "app #250 guard: RH log shows {laps_saved}x 'Current laps saved', {heats_added}x 'Heat added' \ + (a loop would show these climbing without bound)" + ); + assert!( + laps_saved <= 3, + "the heat-end dense save must fire ONCE, not loop: RH logged 'Current laps saved' \ + {laps_saved} times (the #250 regression flooded the socket with a save-per-reconnect loop)" + ); + // One finish should add at most one savable heat (plus the heats present from staging). A loop + // creates a fresh heat every reconnect — climbing without bound. + assert!( + heats_added <= 4, + "the heat-end dense save must add a savable heat ONCE, not loop: RH logged 'Heat added' \ + {heats_added} times (the #250 regression created heat after heat)" + ); + + // 2. Laps actually landed — the live race was NOT interrupted by the finish. The earlier passes + // assertion proves laps flowed; re-affirm the run still holds them after the finish (the loop + // repeatedly stop_race'd the live heat, so under the regression the count would collapse). + assert!( + count_passes(&read_all(&state)) >= 1, + "laps must still be present after the heat-end save (the #250 loop repeatedly stopped the \ + live race, leaving zero laps)" + ); + + // 3. The connection never flapped during the finish (asserted below: it stays Connected). + // Give the bridge several poll cycles to observe the Finished transition and disarm the heat, + // then assert the connection is *still* Connected — not torn down. We can't prove a negative by + // waiting forever, so we wait a generous window and assert it never left Connected. + let stayed_connected = wait_for(Duration::from_secs(8), || { + // Invert the usual helper: succeed only if it ever LEFT Connected (a regression). If it + // never does, `wait_for` returns false after the window — which is what we want. + timers.get(&rh_timer.id).map(|t| t.status) != Some(TimerStatus::Connected) + }) + .await; + assert!( + !stayed_connected, + "the persistent connection must STAY Connected after a heat finishes (the heat is disarmed, \ + the socket is not torn down) — that is the whole point of #105; status = {:?}", + timers.get(&rh_timer.id).map(|t| t.status) + ); + + eprintln!( + "app RH-persistent-connect e2e: connected ON SELECTION (before any heat), {pass_count} real \ + pass(es) into the event log while the heat ran, and STILL Connected after it finished" + ); + + // === Drop detection (#105): stop the RH container out from under the live connection and assert + // the Director observes the drop within ~10s. This is the regression the fix targets: with + // `rust_socketio`'s auto-reconnect the emit-buffering hid a real drop and the timer stayed + // Connected indefinitely. With `.reconnect(false)` + the `close`/`error` handlers flipping the + // alive flag, the driver's monitor now catches it and moves the timer off Connected. === + eprintln!("app RH-drop-detection: stopping the RH container to simulate a timer drop-off…"); + rh.stop(); + let drop_start = Instant::now(); + let dropped = wait_for(Duration::from_secs(10), || { + matches!( + timers.get(&rh_timer.id).map(|t| t.status), + Some(TimerStatus::Disconnected) + | Some(TimerStatus::Error) + | Some(TimerStatus::Connecting) + ) + }) + .await; + let dropped_status = timers.get(&rh_timer.id).map(|t| t.status); + assert!( + dropped, + "the Director must detect a stopped RotorHazard and move the timer off Connected within \ + 10s (the whole point of #105's drop detection); status = {dropped_status:?}" + ); + eprintln!( + "app RH-drop-detection: timer left Connected after RH was stopped — status {dropped_status:?} \ + in {:?} (drop DETECTED)", + drop_start.elapsed() + ); +} + +/// DISTINCT RH host port for the primary/alternate failover e2e (avoids the 5042 above). +const RH_FAILOVER_PORT: u16 = 5043; + +/// Primary RH + alternate Mock **failover** over a live connection (issue #112). +/// +/// The end-to-end proof of the single-active-source feed + failover: with a **primary RH** timer +/// (live, dockerized) and an **alternate Mock** both selected for the running heat, only the RH +/// primary's passes feed the log while it is healthy — the Mock alternate is hot standby (gated +/// off). When the RH container is **stopped mid-heat** the Director fails over: the primary leaves +/// `Connected`, and the Mock alternate's synthetic passes take over so laps keep landing. This is +/// the redundancy guarantee — a dropped primary does not stop the race log. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires Docker (primary RH + alternate Mock; stops the RH container mid-heat and asserts the Mock alternate takes over)"] +async fn director_fails_over_from_a_dropped_rh_primary_to_a_mock_alternate() { + let scenario = vec![( + 0usize, + node_csv(&NodeCsv { + ticks_per_lap: 2, + peak_rssi: 180, + baseline_rssi: 70, + seed: 0, + }), + )]; + let rh = RhContainer::start(RH_FAILOVER_PORT, TICK, &scenario); + + let registry = EventRegistry::new(None).expect("event registry"); + // A brisk, long-running Mock alternate so it still has passes to emit after the failover (a + // hot-standby Mock runs its emission in real time; failover catches the not-yet-emitted passes). + registry + .timers() + .update( + &TimerId(MOCK_TIMER_ID.to_string()), + &gridfpv_server::timers::UpdateTimerRequest { + name: None, + kind: Some(TimerKind::Mock { + laps: 600, + lap_ms: 100, + }), + ..Default::default() + }, + ) + .expect("retune the Mock alternate to a long, brisk run"); + let rh_timer = registry + .timers() + .create(&CreateTimerRequest { + name: "Field RH".into(), + kind: TimerKind::Rotorhazard { + url: rh.url().to_string(), + }, + channel_capability: None, + node_count: None, + available_channels: None, + }) + .expect("create RH timer"); + + registry + .set_active(&practice()) + .expect("make Practice active"); + // Select [RH, Mock] with the RH as the explicit PRIMARY and the Mock as the alternate. + registry + .set_timers( + &practice(), + vec![rh_timer.id.clone(), TimerId(MOCK_TIMER_ID.to_string())], + ) + .expect("select RH primary + Mock alternate"); + registry + .set_primary_timer(&practice(), Some(rh_timer.id.clone())) + .expect("designate the RH timer primary"); + + let state = registry.resolve(&practice()).expect("Practice state"); + let _bridge = spawn_registry_bridge( + registry.clone(), + SourceConfig::from_env(), + AdapterId(SIM_ADAPTER.to_string()), + ); + + // The RH primary connects on selection. + let timers = registry.timers(); + let id = rh_timer.id.clone(); + assert!( + wait_for(Duration::from_secs(30), || { + timers.get(&id).map(|t| t.status) == Some(TimerStatus::Connected) + }) + .await, + "the RH primary should reach Connected on selection" + ); + + // Run the heat through the real lifecycle (Scheduled → Staged → Armed → Running); while the RH + // primary is healthy, its passes feed (the Mock alternate is gated). Staged prepares the RH + // connection for an instant start (Grid owns all timing). + let heat = HeatId("q-fo-1".into()); + let pilot = CompetitorRef("Ace".into()); + state + .append( + Event::HeatScheduled { + heat: heat.clone(), + lineup: vec![pilot.clone()], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + for transition in [ + HeatTransition::Staged, + HeatTransition::Armed, + HeatTransition::Running, + ] { + state + .append( + Event::HeatStateChanged { + heat: heat.clone(), + transition, + }, + None, + ) + .unwrap(); + tokio::time::sleep(Duration::from_millis(700)).await; + } + + assert!( + wait_for(Duration::from_secs(40), || count_passes(&read_all(&state)) + >= 1) + .await, + "the RH primary's passes should feed while it is healthy" + ); + let before_drop = count_passes(&read_all(&state)); + eprintln!("app failover e2e: {before_drop} pass(es) from the RH primary before the drop"); + + // === Stop the RH container mid-heat: the primary drops and the Mock alternate takes over. === + eprintln!("app failover e2e: stopping the RH primary container mid-heat…"); + rh.stop(); + assert!( + wait_for(Duration::from_secs(15), || { + matches!( + timers.get(&rh_timer.id).map(|t| t.status), + Some(TimerStatus::Disconnected) + | Some(TimerStatus::Error) + | Some(TimerStatus::Connecting) + ) + }) + .await, + "the Director must detect the dropped RH primary" + ); + + // The Mock alternate now feeds: the pass count keeps growing past the pre-drop total even though + // the RH primary is dead. This is the failover — a dropped primary does not stop the log. + let target = before_drop + 2; + assert!( + wait_for(Duration::from_secs(20), || count_passes(&read_all(&state)) + >= target) + .await, + "the Mock alternate should take over and keep laps landing after the RH primary dropped; \ + passes = {} (wanted ≥ {target})", + count_passes(&read_all(&state)) + ); + eprintln!( + "app failover e2e: Mock alternate took over after the RH primary dropped — {} total pass(es) \ + (failover CONFIRMED)", + count_passes(&read_all(&state)) + ); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 1b53548..c8cb0e7 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gridfpv-engine" -version = "0.1.0" +version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true @@ -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..55658d9 100644 --- a/crates/engine/src/event.rs +++ b/crates/engine/src/event.rs @@ -16,7 +16,7 @@ //! ranking — the seeds, best first. //! 2. **Seed the bracket.** [`crate::format::advance_top_n`] takes the top `bracket_size` //! of that ranking and feeds them, *in qualifying-rank order*, as the seeded field of -//! a [`crate::single_elim::SingleElim`]. +//! a per-level bracket generator (caller-supplied). //! 3. **Bracket.** The bracket runs to completion; its winner is the survivor at the top //! of its final ranking, and that ranking is the final event standings. //! @@ -35,18 +35,19 @@ //! A heat's log may carry marshaling adjudications ([`gridfpv_events::Event::DetectionVoided`], //! [`gridfpv_events::Event::LapInserted`], [`gridfpv_events::Event::LapAdjusted`]). The //! raw passes are never mutated (architecture.html §3); instead [`score_marshaled`] -//! builds the **corrected view** of the lap-gate passes — exactly as -//! [`gridfpv_projection::lap_list_marshaled`] does — and scores *that*, so an +//! scores the **corrected view** of the lap-gate passes built by +//! [`gridfpv_projection::corrected_passes`] — the single home of the void/insert/adjust +//! fold that [`gridfpv_projection::lap_list_marshaled`] also folds through (#39) — so an //! adjudication in any heat flows straight through into the qualifying ranking and the //! bracket via the same scorer the un-marshaled path uses. #![forbid(unsafe_code)] -use std::collections::BTreeMap; - -use gridfpv_events::{Event, Pass, SourceTime}; +use gridfpv_events::{Event, 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}; +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 @@ -92,7 +93,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, @@ -114,16 +116,28 @@ impl EventOutcome { } /// Run a full event: drive `qualifying` to completion, take the top `bracket_size` of -/// its ranking into `bracket`, drive that to a single winner, and return both rankings. +/// its ranking into the bracket, run the bracket **level by level** to a single winner, and +/// return both rankings. +/// +/// The qualifying generator and a per-**level** bracket generator factory are supplied by the +/// caller (so the qualifying win condition / metric and the bracket's `heat_size` are the +/// caller's choice); the same `run` closure scores every heat in both phases. `bracket_size` +/// clamps to the qualifying field, so a short field simply takes everyone into the bracket. +/// +/// # Level-per-round (decisions D13, #217) +/// +/// A single-elimination bracket is now **one round per level**, not one generator for the whole +/// bracket: `make_level` builds a fresh per-level [`Generator`] seeded with that level's field, +/// emits exactly that level's heats, and completes. This driver chains the levels — the **first** +/// level is seeded from the quali top-N, and each **next** level is seeded from the prior level's +/// advancers (its ranking, winners first — the same `FromHeatWinners` carry the live engine does +/// round-to-round) — until a single competitor remains. The returned `bracket` ranking is the +/// **final level's** placement (winner first), `bracket_heats` are every level's heats in order. /// -/// The two generators are constructed by the caller (so the qualifying win condition / -/// metric and the bracket's `heat_size` are the caller's choice); the same `run` closure -/// scores every heat in both phases. `bracket_size` clamps to the qualifying field, so a -/// short field simply takes everyone into the bracket. Pure orchestration over the -/// existing pieces — deterministic given a deterministic `run`. +/// Pure orchestration over the existing pieces — deterministic given a deterministic `run`. pub fn run_event( qualifying: &mut dyn Generator, - make_bracket: impl FnOnce(Vec) -> Box, + mut make_level: impl FnMut(Vec) -> Box, bracket_size: usize, run: &mut dyn RunHeat, max_heats: usize, @@ -131,12 +145,42 @@ pub fn run_event( // Phase 1 — qualifying to a ranking. let (qualifying_heats, qualifying) = run_format(qualifying, run, max_heats); - // Seed the bracket from the top of the qualifying ranking, in rank order. + // Seed the first bracket level from the top of the qualifying ranking, in rank order. let bracket_seeds = advance_top_n(&qualifying, bracket_size); - // Phase 2 — the bracket to a single winner. - let mut bracket_gen = make_bracket(bracket_seeds.clone()); - let (bracket_heats, bracket) = run_format(bracket_gen.as_mut(), run, max_heats); + // Phase 2 — the bracket, level by level. Each level is its own generator seeded from the + // previous level's advancers (winners in heat order); the chain ends when a level produces + // a single survivor. `bracket` holds the latest level's ranking (the final standings). + let mut bracket_heats: Vec = Vec::new(); + let mut level_seeds = bracket_seeds.clone(); + let mut bracket: Vec = qualifying_seed_ranking(&level_seeds); + + let mut level = 0usize; + while level_seeds.len() > 1 { + assert!( + level < max_heats, + "bracket ran more than {max_heats} levels without resolving a winner" + ); + level += 1; + + // Run this one level. Each level reuses the generator's own heat ids (`se-h0`, …), so + // scope them per level (`l{level}-…`) before scoring — mirroring how the live engine + // scopes heat ids per round — so a fixture keyed by heat id never collides across levels. + let mut level_gen = make_level(level_seeds.clone()); + let (level_heats, level_ranking) = run_level(level_gen.as_mut(), run, max_heats, level); + bracket_heats.extend(level_heats); + bracket = level_ranking; + + // The level's advancers (its ranking ahead of the eliminated) seed the next level. A + // single-elim level eliminates exactly the competitors tied at the worst position, so + // the advancers are everyone above that band — preserving winners-first heat order. + let advancers = level_advancers(&bracket); + // Guard against a degenerate level that fails to shrink the field (would loop forever). + if advancers.len() >= level_seeds.len() { + break; + } + level_seeds = advancers; + } EventOutcome { qualifying, @@ -147,6 +191,65 @@ pub fn run_event( } } +/// Drive one bracket **level** to completion, scoping each emitted heat id with the level number +/// (`l{level}-{id}`) so a fixture keyed by heat id never confuses two levels that reuse the same +/// per-level ids — the engine analogue of the server scoping a round's heat ids per round. +fn run_level( + generator: &mut dyn Generator, + run: &mut dyn RunHeat, + max_heats: usize, + level: usize, +) -> (Vec, Vec) { + let mut completed: Vec = Vec::new(); + while let GeneratorStep::Run(plans) = generator.next(&completed) { + for plan in &plans { + assert!( + completed.len() < max_heats, + "level ran more than {max_heats} heats without completing" + ); + // Scope the heat id with the level so the fixture / log can tell levels apart, but + // hand the generator back its OWN id (it keyed `next` on the unscoped ids). + let scoped = HeatPlan::new(format!("l{level}-{}", plan.heat.0), plan.lineup.clone()); + let result = run.run(&scoped); + completed.push(CompletedHeat::new(plan.heat.0.clone(), result)); + } + } + let ranking = generator.ranking(&completed); + (completed, ranking) +} + +/// A trivial 1, 2, 3, … ranking from a seed order — the bracket standings before any level has +/// run (the seeds in qualifying-rank order), so a degenerate (≤1-seed) bracket still reports a +/// ranking. +fn qualifying_seed_ranking(seeds: &[gridfpv_events::CompetitorRef]) -> Vec { + seeds + .iter() + .enumerate() + .map(|(i, competitor)| RankEntry { + competitor: competitor.clone(), + position: i as u32 + 1, + }) + .collect() +} + +/// The competitors **advancing** out of a single-elim level — everyone the level did *not* +/// eliminate. A level's [`Generator::ranking`] lists the advancers first (winners, in heat order, +/// at distinct positions) and the eliminated last, all **tied at the worst position** (each heat's +/// losers share the single bottom band). So the advancers are exactly the entries whose `position` +/// is strictly better than that worst band — which preserves the winners-first heat order the next +/// level seeds from. A degenerate level whose ranking is all one band advances no one (the loop +/// guards against that). +fn level_advancers(ranking: &[RankEntry]) -> Vec { + let Some(worst) = ranking.iter().map(|e| e.position).max() else { + return Vec::new(); + }; + ranking + .iter() + .filter(|e| e.position < worst) + .map(|e| e.competitor.clone()) + .collect() +} + // --- Marshaling-aware scoring ---------------------------------------------- /// Score a heat's event log under `condition`, **folding in any marshaling @@ -154,163 +257,53 @@ pub fn run_event( /// /// Scoring proper ([`score`]) consumes raw lap-gate [`Pass`]es; marshaling corrections /// are appended events that must be folded into a *corrected view* of those passes -/// before scoring — never by mutating the raw passes (architecture.html §3). This builds -/// that corrected pass stream exactly as [`gridfpv_projection::lap_list_marshaled`] does -/// — voiding voided detections, inserting recovered laps, re-timing adjusted ones, with -/// the same last-writer-wins-by-offset and "void the void" resolution — and then scores -/// it. A log with no adjudications produces the raw pass stream unchanged, so this agrees -/// with [`crate::scoring::score_events`] byte-for-byte on a clean log. +/// before scoring — never by mutating the raw passes (architecture.html §3). +/// +/// The void/insert/adjust fold lives in **one** place — [`gridfpv_projection::corrected_passes`] +/// — and this scorer simply consumes its output (#39): rather than re-implement the same +/// last-writer-wins-by-offset / "void the void" resolution here, we hand the log's +/// positional `(offset, event)` pairs to the projection's fold (the storage layer assigns +/// these same dense append offsets) and score the corrected pass stream it returns. A log +/// with no adjudications yields the raw pass stream unchanged, so this agrees with +/// [`crate::scoring::score_events`] byte-for-byte on a clean log, and stays in lock-step +/// 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, race_start: SourceTime, ) -> HeatResult { - score(&corrected_passes(events), condition, race_start) -} - -/// Build the **corrected** lap-gate pass stream from a heat log: apply every marshaling -/// adjudication by the offset it targets, leaving raw passes untouched, and return the -/// surviving passes (synthetic inserts included) as a fresh `Vec`. -/// -/// This mirrors [`gridfpv_projection::lap_list_marshaled`]'s fold so the scorer and the -/// lap projection agree on the corrected view; rather than duplicate every rule here it -/// resolves the same three adjudications keyed on the target's positional offset: -/// -/// - [`Event::DetectionVoided`] drops the target pass / ruling from the view, -/// - [`Event::LapInserted`] adds a synthetic lap-gate pass, -/// - [`Event::LapAdjusted`] re-times the target pass, -/// -/// with last-writer-wins by offset and the "void the void" / "void the adjust" cases -/// resolved exactly as the projection does. The returned passes carry absolute -/// [`SourceTime`]s (a re-time moves a pass; an insert slots in at its `at`) so the -/// timed/first-to-N conditions still see real completion times. -fn corrected_passes(events: &[Event]) -> Vec { - // An entry the fold can target by its append offset: a raw pass, a synthetic insert, - // or a ruling against another offset. - enum Entry<'a> { - RawPass(&'a Pass), - Inserted(Pass), - Adjusted { target: u64, at: SourceTime }, - Voided { target: u64 }, - } - - // First pass: index every fold-relevant event by its positional offset (the storage - // layer assigns these same dense offsets, so positional indexing mirrors the log). - let mut entries: BTreeMap> = BTreeMap::new(); - for (offset, event) in events.iter().enumerate() { - let offset = offset as u64; - match event { - Event::Pass(pass) if pass.gate.is_lap_gate() => { - entries.insert(offset, Entry::RawPass(pass)); - } - Event::LapInserted { - adapter, - competitor, - at, - } => { - entries.insert( - offset, - Entry::Inserted(Pass { - adapter: adapter.clone(), - competitor: competitor.clone(), - at: *at, - // A synthetic pass carries no source sequence; ordered by `at`. - sequence: None, - gate: gridfpv_events::GateIndex::LAP, - signal: None, - }), - ); - } - Event::LapAdjusted { target, at } => { - entries.insert( - offset, - Entry::Adjusted { - target: target.0, - at: *at, - }, - ); - } - Event::DetectionVoided { target } => { - entries.insert(offset, Entry::Voided { target: target.0 }); - } - // Splits, lifecycle, heat transitions, and heat/result-level rulings never - // touch the lap-gate pass view. - _ => {} - } - } - - // Resolve rulings in offset order (BTreeMap iterates ascending), last writer winning. - // `voided[off]` drops an offset from the view; `retime[off]` overrides a pass's `at`. - let mut voided: BTreeMap = BTreeMap::new(); - let mut retime: BTreeMap = BTreeMap::new(); - for entry in entries.values() { - match entry { - Entry::RawPass(_) | Entry::Inserted(_) => {} - Entry::Adjusted { target, at } => { - // An adjust is the newest ruling on its target: un-void and re-time it. - voided.insert(*target, false); - retime.insert(*target, *at); - } - Entry::Voided { target } => match entries.get(target) { - // Voiding an adjust cancels its re-time (target reverts to its raw `at`). - Some(Entry::Adjusted { - target: inner_target, - .. - }) => { - retime.remove(inner_target); - } - // Voiding a void resurrects the originally-voided target. - Some(Entry::Voided { - target: inner_target, - }) => { - voided.insert(*inner_target, false); - } - // Voiding a raw or inserted pass simply drops it. - _ => { - voided.insert(*target, true); - } - }, - } - } - - // Emit the surviving passes (raw + inserted) with any re-time applied, in offset - // order; the scorer re-groups and re-orders them by competitor itself. - let mut out: Vec = Vec::new(); - for (offset, entry) in entries.iter() { - if voided.get(offset).copied().unwrap_or(false) { - continue; - } - match entry { - Entry::RawPass(pass) => { - let mut p = (*pass).clone(); - if let Some(at) = retime.get(offset) { - p.at = *at; - } - out.push(p); - } - Entry::Inserted(pass) => { - let mut p = pass.clone(); - if let Some(at) = retime.get(offset) { - p.at = *at; - } - out.push(p); - } - Entry::Adjusted { .. } | Entry::Voided { .. } => {} - } - } - out + // The single home of the marshaling fold is `gridfpv_projection::corrected_passes`; + // tag each event with its positional append offset and fold there, then score the + // corrected lap-gate passes it returns. The scorer re-groups/re-orders by competitor. + // `corrected_passes` pairs each surviving pass with the global offset that addresses it + // — **kept** here so a `LapThrownOut` (whose target is a lap's end-pass offset) excludes + // the matching lap from the scored count. + let corrected: Vec<(u64, gridfpv_events::Pass)> = + gridfpv_projection::corrected_passes(events.iter().enumerate().map(|(i, e)| (i as u64, e))); + // Penalties / heat-void / throw-outs 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)] mod tests { + use std::collections::BTreeMap; + use super::*; use crate::scoring::{Metric, score_events}; - use crate::single_elim::SingleElim; use crate::timed_qual::{QualMetric, TimedQualifying}; - use gridfpv_events::{AdapterId, CompetitorRef, GateIndex, LogRef}; + use gridfpv_events::{AdapterId, CompetitorRef, GateIndex, LogRef, Pass}; const ADAPTER: &str = "vd"; @@ -330,6 +323,7 @@ mod tests { sequence: Some(seq), gate: GateIndex::LAP, signal: None, + heat: None, }) } @@ -388,6 +382,7 @@ mod tests { adapter: AdapterId(ADAPTER.into()), competitor: cref("A"), at: SourceTime::from_micros(3_000_000), + heat: None, }, // offset 2 ]; let start = SourceTime::from_micros(0); @@ -398,6 +393,55 @@ mod tests { assert_eq!(score_marshaled(&log, cond, start).places[0].laps, 2); } + #[test] + fn lap_thrown_out_excludes_a_lap_through_the_marshaled_path() { + // The marshaled scorer (corrected_passes → apply_adjudications) excludes a thrown-out lap + // by its corrected end-pass offset. A has 3 laps (4 passes at offsets 0..3); throw out the + // lap ending at offset 2 → 2 counted laps. Proves the offset is preserved end-to-end. + let clean = vec![ + pass("A", 0, 0), // offset 0 + pass("A", 3_000_000, 1), // offset 1 + pass("A", 6_000_000, 2), // offset 2 + pass("A", 9_000_000, 3), // offset 3 + ]; + let mut thrown = clean.clone(); + thrown.push(Event::LapThrownOut { + target: gridfpv_events::LogRef(2), + }); // offset 4 + let cond = WinCondition::Timed { + window_micros: 60_000_000, + }; + let start = SourceTime::from_micros(0); + // Clean: 3 laps. With the throw-out: 2 counted laps (via the marshaled path). + assert_eq!(score_marshaled(&clean, cond, start).places[0].laps, 3); + assert_eq!(score_marshaled(&thrown, cond, start).places[0].laps, 2); + } + + #[test] + fn lap_thrown_out_excludes_an_inserted_lap_through_the_marshaled_path() { + // A throw-out targeting an INSERTED lap (its end_ref is the LapInserted offset) is excluded + // by the marshaled scorer — the corrected synthetic pass carries that offset. + let log = vec![ + pass("A", 0, 0), // offset 0 + pass("A", 6_000_000, 1), // offset 1 + Event::LapInserted { + adapter: AdapterId(ADAPTER.into()), + competitor: cref("A"), + at: SourceTime::from_micros(3_000_000), + heat: None, + }, // offset 2 — inserts a lap → A has 2 laps + Event::LapThrownOut { + target: gridfpv_events::LogRef(2), + }, // offset 3 — throw out the lap ending at the inserted pass + ]; + let cond = WinCondition::Timed { + window_micros: 60_000_000, + }; + let start = SourceTime::from_micros(0); + // The insert gives 2 laps; throwing out the inserted lap's end drops it back to 1 counted. + assert_eq!(score_marshaled(&log, cond, start).places[0].laps, 1); + } + // --- run_format / run_event -------------------------------------------- /// A fixed map from heat id to its scored result — a fixture "run a heat" closure. @@ -428,27 +472,10 @@ mod tests { position: (i as u32) + 1, laps: 0, metric: Metric::BestLapMicros(*micros), + ..Default::default() }) .collect(), - } - } - - fn h2h(winner: &str, loser: &str) -> HeatResult { - use crate::scoring::Placement; - use gridfpv_projection::CompetitorKey; - HeatResult { - places: [(winner, 1u32, 5u32), (loser, 2, 3)] - .iter() - .map(|(name, position, laps)| Placement { - competitor: CompetitorKey { - adapter: AdapterId(ADAPTER.into()), - competitor: cref(name), - }, - position: *position, - laps: *laps, - metric: Metric::LastLapAt(None), - }) - .collect(), + ..Default::default() } } @@ -458,7 +485,7 @@ mod tests { // Best laps: A 1.6, B 1.8, C 1.7 across two rounds → A, C, B. let mut run = fixture(vec![ ( - "round-1", + "tq-r1-h1", best_lap_result(&[ ("A", Some(2_000_000)), ("B", Some(1_900_000)), @@ -466,7 +493,7 @@ mod tests { ]), ), ( - "round-2", + "tq-r2-h1", best_lap_result(&[ ("A", Some(1_600_000)), ("B", Some(1_800_000)), @@ -478,88 +505,4 @@ mod tests { assert_eq!(heats.len(), 2); assert_eq!(names(&ranking), vec!["A", "C", "B"]); } - - #[test] - fn run_event_qualifying_seeds_bracket_to_a_winner() { - // Four-pilot field: qualifying ranks A,B,C,D; a 4-seed head-to-head bracket - // where the top seed wins every heat → A wins the event. - let mut qual = TimedQualifying::new(field(&["A", "B", "C", "D"]), 1, QualMetric::BestLap); - let mut run = fixture(vec![ - ( - "round-1", - best_lap_result(&[ - ("A", Some(1_500_000)), - ("B", Some(1_600_000)), - ("C", Some(1_700_000)), - ("D", Some(1_800_000)), - ]), - ), - // Bracket round 1: A v D, B v C (bracket order A,D,B,C). - ("se-r1-h0", h2h("A", "D")), - ("se-r1-h1", h2h("B", "C")), - // Final: A v B. - ("se-r2-h0", h2h("A", "B")), - ]); - - let outcome = run_event( - &mut qual, - |seeds| Box::new(SingleElim::new(seeds, 2)), - 4, - &mut run, - 100, - ); - - assert_eq!(names(&outcome.qualifying), vec!["A", "B", "C", "D"]); - assert_eq!(outcome.bracket_seeds, field(&["A", "B", "C", "D"])); - assert_eq!(outcome.winner(), Some(&cref("A"))); - assert_eq!(outcome.bracket[0].position, 1); - assert_eq!( - outcome.bracket.iter().filter(|e| e.position == 1).count(), - 1, - "exactly one event winner" - ); - } - - #[test] - fn run_event_is_deterministic_on_replay() { - let build = || TimedQualifying::new(field(&["A", "B", "C", "D"]), 1, QualMetric::BestLap); - let make_run = || { - fixture(vec![ - ( - "round-1", - best_lap_result(&[ - ("A", Some(1_500_000)), - ("B", Some(1_600_000)), - ("C", Some(1_700_000)), - ("D", Some(1_800_000)), - ]), - ), - ("se-r1-h0", h2h("A", "D")), - ("se-r1-h1", h2h("B", "C")), - ("se-r2-h0", h2h("A", "B")), - ]) - }; - - let mut q1 = build(); - let mut r1 = make_run(); - let first = run_event( - &mut q1, - |s| Box::new(SingleElim::new(s, 2)), - 4, - &mut r1, - 100, - ); - - let mut q2 = build(); - let mut r2 = make_run(); - let second = run_event( - &mut q2, - |s| Box::new(SingleElim::new(s, 2)), - 4, - &mut r2, - 100, - ); - - assert_eq!(first, second, "the full event replays identically"); - } } diff --git a/crates/engine/src/format.rs b/crates/engine/src/format.rs index 5c4995d..e0cd6b1 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, @@ -175,6 +179,22 @@ pub trait Generator { /// has returned [`GeneratorStep::Complete`]. Best-placed competitor first, ties /// sharing a position (see [`RankEntry`]). fn ranking(&self, completed: &[CompletedHeat]) -> Vec; + + /// The competitors that **advance** out of this round — the bracket-advancement carry a + /// `FromHeatWinners` successor level seeds from (decisions D13, #217), in advancement order + /// (each heat's winners in heat order, then byes). + /// + /// The default derives them from [`ranking`](Self::ranking) via [`ranking_advancers`] — every + /// competitor ranked strictly better than the worst (bottom) band. That is correct only when + /// the eliminated all tie at the single worst position (a head-to-head level). A format whose + /// heats eliminate **several** pilots at distinct in-heat positions — a 4-up heat advancing two + /// ranks its two losers at *different* overall positions — **overrides** this to return its real + /// advancing set, so the carry is exactly the winners and not the better-placed losers too. + /// + /// [`ranking`]: Self::ranking + fn advancers(&self, completed: &[CompletedHeat]) -> Vec { + ranking_advancers(&self.ranking(completed)) + } } // --- Advancement & seeding helpers (RE §5) ---------------------------------- @@ -197,6 +217,45 @@ pub fn advance_top_n(ranking: &[RankEntry], n: usize) -> Vec { .collect() } +/// Advance a **ranking slice** — the competitors at positions `skip+1 ..= skip+take`, in ranking +/// order (best first) — the seeds for a *lower* main / consolation bracket (e.g. a C-main = qual +/// seeds 13–20 is `skip = 12, take = 8`). +/// +/// A thin `ranking.iter().skip(skip).take(take)` over the already-total, deterministic +/// [`RankEntry`] order, so the slice is deterministic too (including across a tie at either +/// boundary, where the tie's deterministic intra-group order decides the cut). `skip` past the end +/// of the ranking yields an empty slice; `take == 0` yields empty; a `take` that runs past the end +/// simply returns the remaining competitors. The generalisation of [`advance_top_n`] (which is +/// `advance_range(ranking, 0, n)`) to an arbitrary window. +pub fn advance_range(ranking: &[RankEntry], skip: usize, take: usize) -> Vec { + ranking + .iter() + .skip(skip) + .take(take) + .map(|entry| entry.competitor.clone()) + .collect() +} + +/// The advancers **derived from a ranking** — every competitor ranked **strictly better than the +/// worst position** (the bottom band). The fallback the default [`Generator::advancers`] uses, and +/// the provisional carry a bracket level uses before it completes. +/// +/// This is only correct when the eliminated competitors **all share the single worst position** +/// (one loser per heat, tied last) — true for a head-to-head level. A level whose heats eliminate +/// **several** pilots at **distinct** in-heat positions (a 4-up heat's 3rd + 4th place rank, say, +/// 5th and 7th overall) would wrongly keep the better-placed losers, so such a format **overrides** +/// [`Generator::advancers`] to return its real advancing set rather than relying on this. +pub fn ranking_advancers(ranking: &[RankEntry]) -> Vec { + let Some(worst) = ranking.iter().map(|e| e.position).max() else { + return Vec::new(); + }; + ranking + .iter() + .filter(|e| e.position < worst) + .map(|e| e.competitor.clone()) + .collect() +} + /// Build the next heat's lineup by **seeding from a ranking**: take the competitors in /// `order` and lay them into a heat in that order, which *is* the seed order. /// @@ -228,6 +287,39 @@ pub fn bracket_pairs(seeds: &[CompetitorRef]) -> Vec { out } +/// The **finishing-position points** a `position` (1-based) earns in a heat of `heat_size`, the +/// shared scoring building block (D17) every points-based structure uses so they all score racing +/// the same way. With an explicit per-position `table` (1st most, descending; positions past the +/// table earn 0) the table decides; with no table it falls back to the linear `heat_size − position + +/// 1` (winner of a `k`-up heat gets `k`, last gets `1`). Never negative. +pub fn position_points(table: Option<&[u32]>, position: u32, heat_size: usize) -> i64 { + match table { + Some(t) => t + .get((position as usize).saturating_sub(1)) + .copied() + .unwrap_or(0) as i64, + None => (heat_size as i64 - position as i64 + 1).max(0), + } +} + +/// Parse a per-position **points table** config value — a comma/space-separated list like +/// `"10, 7, 5, 3"` (index 0 = 1st place) — into a table for [`position_points`]. An absent value, or +/// one with no parseable entries, yields `None` (the linear fallback). Shared by every points-based +/// structure so the editable table is read the same way everywhere. +/// +/// A non-numeric token (e.g. the `x` in `"10, x, 3"`) is treated as **0 in place** rather than +/// dropped, so it does not silently *shift* every later position up a place (which would award the +/// wrong points). Empty tokens (from adjacent separators / trailing commas) are skipped. +pub fn parse_points_table(raw: Option<&str>) -> Option> { + let table: Vec = raw? + .split([',', ' ']) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.parse::().unwrap_or(0)) + .collect(); + if table.is_empty() { None } else { Some(table) } +} + // --- Recorded outcomes (RE §6) ---------------------------------------------- /// A **recorded** seeding draw: the resolved outcome of a one-time random ordering, @@ -342,6 +434,107 @@ impl FormatConfig { } } +/// The **kind** of a format parameter (race redesign Slice 7a) — how a UI renders / validates it. +/// +/// A `number` is a free integer/decimal input; an `enum` is a fixed choice from +/// [`options`](FormatParam::options); a `bool` is a toggle. Externally tagged so it maps to a TS +/// discriminated-union-friendly string literal. Derives serde + `ts_rs::TS`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = "bindings/")] +pub enum ParamKind { + /// A numeric input (e.g. `rounds`, `heat_size`). + Number, + /// A fixed choice from [`options`](FormatParam::options) (e.g. a `metric`). + Enum, + /// A boolean toggle (e.g. `bracket_reset`). + Bool, +} + +/// One **parameter schema** entry for a format (race redesign Slice 7a) — the declared shape of a +/// config knob the format's generator reads. +/// +/// The Rounds UI's params editor reads these to render the right control per knob (a number field, +/// an enum dropdown, a toggle) with its default; the server declares one per param each generator +/// actually consumes. Derives serde (its JSON *is* the `GET /formats` wire shape) + `ts_rs::TS`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct FormatParam { + /// The param key as it appears in a round's `params` map (e.g. `"rounds"`). + pub key: String, + /// A human-readable label for the param (e.g. `"Rounds"`). + pub label: String, + /// How the param is rendered / validated. + pub kind: ParamKind, + /// For an [`Enum`](ParamKind::Enum) param, the allowed values (the raw strings stored in + /// `params`); empty for `number` / `bool`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options: Vec, + /// The default value (the raw string the format falls back to when the param is unset), or + /// `None` when the format has no default. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +impl FormatParam { + /// A numeric param with a default. + fn number(key: &str, label: &str, default: &str) -> Self { + Self { + key: key.into(), + label: label.into(), + kind: ParamKind::Number, + options: Vec::new(), + default: Some(default.into()), + } + } + + /// An enum param with its allowed `options` and a default. + /// + /// No standard format declares an enum param at the moment (the qualifying `metric` enum was + /// removed when the qualifying metric became the win condition — Rounds form redesign), but the + /// [`Enum`](ParamKind::Enum) kind and its frontend rendering path are still supported, so this + /// constructor is kept for the next format that needs a fixed-choice param. + #[allow(dead_code)] + fn enumerated(key: &str, label: &str, options: &[&str], default: &str) -> Self { + Self { + key: key.into(), + label: label.into(), + kind: ParamKind::Enum, + options: options.iter().map(|s| s.to_string()).collect(), + default: Some(default.into()), + } + } + + /// A boolean param with a default (`"1"` / `"0"`). + /// + /// No standard format currently declares a boolean param (the only user, `double_elim`'s + /// `bracket_reset`, was carved out for the primitives-first release), but the + /// [`Bool`](ParamKind::Bool) kind and its frontend rendering path are still supported, so this + /// constructor is kept for the next format that needs a toggle param. + #[allow(dead_code)] + fn boolean(key: &str, label: &str, default: &str) -> Self { + Self { + key: key.into(), + label: label.into(), + kind: ParamKind::Bool, + options: Vec::new(), + default: Some(default.into()), + } + } +} + +/// A format's **declared param schema** (race redesign Slice 7a): the format name plus the params +/// its generator reads. The shape `GET /formats` returns so the Rounds UI renders a params editor. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct FormatSchema { + /// The format name (a [`FormatRegistry::standard`] name). + pub name: String, + /// The params this format's generator reads, in display order. + pub params: Vec, +} + /// A constructor that builds a boxed [`Generator`] from a [`FormatConfig`]. pub type FormatCtor = fn(&FormatConfig) -> Box; @@ -371,11 +564,100 @@ impl FormatRegistry { self.ctors.insert(name.into(), ctor); } + /// A registry pre-populated with every **production** format the engine ships: + /// [`timed_qual`](crate::timed_qual::TimedQualifying), [`zippyq`](crate::zippyq::ZippyQ), + /// [`head_to_head`](crate::head_to_head::HeadToHead), and the casual + /// [`open_practice`](OpenPractice) format. + /// + /// This is the single authority for "which format names are valid": the server validates a + /// round's configured format name against [`names`](Self::names) / [`contains`](Self::contains) + /// of this registry. The `*-demo` formats in this module are test fixtures and are deliberately + /// **not** registered here. + pub fn standard() -> Self { + let mut registry = Self::new(); + crate::timed_qual::TimedQualifying::register(&mut registry); + crate::zippyq::ZippyQ::register(&mut registry); + // Head-to-Head (D17): the atomic racing building block the structures compose. Registered so + // its rounds build/replay; UI offering + the points-table editor land with the rollout. + crate::head_to_head::HeadToHead::register(&mut registry); + OpenPractice::register(&mut registry); + registry + } + /// The format names registered, in sorted order. pub fn names(&self) -> Vec<&str> { self.ctors.keys().map(String::as_str).collect() } + /// The **param schema** for every **offered** production format (race redesign Slice 7a), in + /// sorted name order. This is the **offered set** the Rounds UI picks from — a subset of the + /// registered formats in [`standard`](Self::standard): a format can be registered (so its + /// persisted rounds still validate and replay) yet excluded here so it can't be selected for a + /// new round. ZippyQ is currently in exactly that state — shelved (#218), registered but not + /// offered. + /// + /// Each entry declares the params that format's generator actually reads (with kind, options, + /// and default), the single source of truth `GET /formats` returns so the Rounds UI renders a + /// per-format params editor. Kept in lock-step with the generators' `from_config` readers: + /// + /// - `timed_qual`: `rounds` ("Heats per pilot", number, 3). The ranking metric is **derived + /// from the round's win condition** (the qualifying metric *is* the win condition), so it is + /// **not** a separate param here. + /// - `head_to_head`: `group_size` ("Group size", 2), `rotations` ("Heats per group", 1 — the + /// same groups run back to back N times, scoring accumulating; the grouping is drawn once, + /// so re-grouping between heats stays a tournament-structure job), `scoring` ("Scoring", + /// placement/points). + /// - `open_practice`: no params (the active channels are the field, carried by the round's + /// `AllChannels` seeding). + /// - `zippyq`: **not offered** — shelved (#218); still registered in + /// [`standard`](Self::standard) so persisted rounds load, but omitted from this offered set. + pub fn standard_schemas() -> Vec { + vec![ + // Head-to-Head (D17): the atomic racing round type the Add-round picker offers. Group + // size + rotations (heats per group — one grouping decision, run N times) + scoring + // (Placement, or Points — the per-position points table is authored by a dedicated + // editor in the round form, not a generic param field). + FormatSchema { + name: "head_to_head".into(), + params: vec![ + FormatParam::number("group_size", "Group size", "2"), + FormatParam::number("rotations", "Heats per group", "1"), + FormatParam::enumerated( + "scoring", + "Scoring", + &["placement", "points"], + "placement", + ), + ], + }, + // Open practice (open-practice format): one open heat over the active channels, no + // config knobs (the active channels are the field, carried by the round's seeding — + // see [`OpenPractice`]). Kept in sorted name order (after `head_to_head`, before + // `timed_qual`) to match [`names`](Self::names). + FormatSchema { + name: OpenPractice::NAME.into(), + params: Vec::new(), + }, + FormatSchema { + name: "timed_qual".into(), + // No `metric` param: the qualifying metric is **derived from the round's win + // condition** (the qualifying metric *is* the win condition — Rounds form redesign), + // not a separate stored knob. The `rounds` param is "Heats per pilot": how many heats + // each pilot flies in the qualifying round (their best flight ranks). + params: vec![FormatParam::number("rounds", "Heats per pilot", "3")], + }, + // NOTE: ZippyQ shelved (#218) — re-add to the offered set when needed. The generator + // stays registered in [`standard`](Self::standard) (so persisted `zippyq` rounds still + // validate and replay), but it is deliberately omitted from the offered schema set here + // so the Rounds UI's format picker can't select it for a new round (decisions D10). + ] + } + + /// Whether a format is registered under `name`. + pub fn contains(&self, name: &str) -> bool { + self.ctors.contains_key(name) + } + /// Build a generator for the format registered under `name`, or `None` if no such /// format is registered. pub fn build(&self, name: &str, config: &FormatConfig) -> Option> { @@ -412,6 +694,71 @@ pub fn rank_by(rows: Vec<(CompetitorRef, K)>) -> Vec out } +// --- Open practice (open-practice format) ----------------------------------- + +/// The **open-practice** format (casual-practice mode): a round is **one open heat** over the +/// active **channels**, run once. +/// +/// Unlike the competitive formats, open practice is keyed on *channels*, not pilots: the RD picks +/// which video channels are live, and the round's field is exactly those channels as source-local +/// `node-{i}` [`CompetitorRef`]s (the seat handles the timer emits passes for). The generator emits +/// a single heat lining up those channels, then declares the format +/// [`Complete`](GeneratorStep::Complete) — there is no advancement, no ranking aggregation, no +/// second heat. Laps are tracked **per channel, live and in memory** (not logged) by the Director's +/// open-practice accumulator; this generator only decides the one heat that runs. +/// +/// The field comes in via [`FormatConfig::field`] (the active channels, in node order). An empty +/// field still emits the (empty) heat then completes — the server's field builder is what rejects +/// an open-practice round with no active channels. +pub struct OpenPractice { + /// The active channels as `node-{i}` competitor refs, in node order — the one heat's lineup. + channels: Vec, +} + +impl OpenPractice { + /// The format name this registers under (the `RoundDef::format` value an open-practice round + /// carries). + pub const NAME: &'static str = "open_practice"; + + /// The id of the single open heat this format emits. + const HEAT: &'static str = "open-practice"; + + /// Build over the active channels (the seeded field, in node order). + pub fn new(channels: Vec) -> Self { + Self { channels } + } + + /// The registry constructor: the active channels are the seeded field (the round's + /// `AllChannels` seeding laid them out as `node-{i}` refs); no params, no draw. + pub fn from_config(config: &FormatConfig) -> Box { + Box::new(Self::new(config.seeding.apply(&config.field))) + } + + /// Register the open-practice format under [`NAME`](Self::NAME). + pub fn register(registry: &mut FormatRegistry) { + registry.register(Self::NAME, Self::from_config); + } +} + +impl Generator for OpenPractice { + fn next(&mut self, completed: &[CompletedHeat]) -> GeneratorStep { + // Exactly one heat ever: emit it while nothing has been completed, otherwise the format is + // done. The open heat carries the active channels as its lineup. + if completed.is_empty() { + GeneratorStep::Run(vec![HeatPlan::new(Self::HEAT, self.channels.clone())]) + } else { + GeneratorStep::Complete + } + } + + fn ranking(&self, _completed: &[CompletedHeat]) -> Vec { + // Open practice has no competitive ranking — it is casual per-channel lap practice. The + // channels are listed in node order so a caller reading a "ranking" gets a stable, if + // meaningless, ordering rather than nothing. + seed_ranking(&self.channels) + } +} + // --- Demo generators (exercise the contract) -------------------------------- /// A trivial **fixed-schedule** demo: a two-round seeded knockout that exercises the @@ -677,8 +1024,10 @@ mod tests { position: *position, laps: *laps, metric: Metric::LastLapAt(None), + ..Default::default() }) .collect(), + ..Default::default() } } @@ -701,6 +1050,62 @@ mod tests { assert_eq!(advance_top_n(&ranking, 5), field(&["A", "B"])); } + // --- advance_range ------------------------------------------------------ + + #[test] + fn advance_range_takes_the_slice_in_order() { + // The B-main slice: seeds 3–4 of a 6-deep ranking (skip 2, take 2). + let ranking = seed_ranking(&field(&["A", "B", "C", "D", "E", "F"])); + assert_eq!(advance_range(&ranking, 2, 2), field(&["C", "D"])); + } + + #[test] + fn advance_range_skip_zero_is_top_n() { + let ranking = seed_ranking(&field(&["A", "B", "C", "D"])); + assert_eq!(advance_range(&ranking, 0, 2), advance_top_n(&ranking, 2)); + } + + #[test] + fn advance_range_take_zero_is_empty() { + let ranking = seed_ranking(&field(&["A", "B", "C"])); + assert!(advance_range(&ranking, 1, 0).is_empty()); + } + + #[test] + fn advance_range_skip_past_end_is_empty() { + let ranking = seed_ranking(&field(&["A", "B"])); + assert!(advance_range(&ranking, 5, 3).is_empty()); + } + + #[test] + fn advance_range_take_past_end_clamps() { + let ranking = seed_ranking(&field(&["A", "B", "C"])); + // skip 1, take 10 → just the remaining two. + assert_eq!(advance_range(&ranking, 1, 10), field(&["B", "C"])); + } + + // --- parse_points_table ------------------------------------------------- + + #[test] + fn parse_points_table_reads_a_plain_list() { + assert_eq!( + parse_points_table(Some("10, 7, 5, 3")), + Some(vec![10, 7, 5, 3]) + ); + } + + #[test] + fn parse_points_table_treats_a_bad_token_as_zero_in_place() { + // The `x` must NOT shift 3 up into 2nd place: it becomes a 0 where it stands. + assert_eq!(parse_points_table(Some("10, x, 3")), Some(vec![10, 0, 3])); + } + + #[test] + fn parse_points_table_none_for_absent_or_unparseable() { + assert_eq!(parse_points_table(None), None); + assert_eq!(parse_points_table(Some(" ")), None); + } + // --- bracket_pairs ------------------------------------------------------ #[test] @@ -914,6 +1319,42 @@ mod tests { assert_eq!(g1.next(&[]), g2.next(&[])); } + // --- OpenPractice: one open heat over the active channels ---------------- + + #[test] + fn open_practice_emits_one_heat_with_the_active_channels_then_completes() { + // The active channels are the field, as `node-{i}` refs in node order. + let channels = field(&["node-0", "node-2", "node-5"]); + let mut generator = OpenPractice::new(channels.clone()); + + // First call: one open heat lining up exactly the active channels. + assert_eq!( + generator.next(&[]), + GeneratorStep::Run(vec![HeatPlan::new("open-practice", channels.clone())]) + ); + + // Once that heat has completed, the format is done — no second heat, ever. + let done = CompletedHeat::new("open-practice", result(&[("node-0", 1, 5)])); + assert_eq!( + generator.next(std::slice::from_ref(&done)), + GeneratorStep::Complete + ); + } + + #[test] + fn open_practice_builds_its_heat_from_the_config_field() { + // The registry constructor reads the active channels off the config field. + let cfg = FormatConfig::new(field(&["node-0", "node-1"])); + let mut generator = OpenPractice::from_config(&cfg); + assert_eq!( + generator.next(&[]), + GeneratorStep::Run(vec![HeatPlan::new( + "open-practice", + field(&["node-0", "node-1"]) + )]) + ); + } + // --- FormatRegistry ----------------------------------------------------- #[test] @@ -937,6 +1378,50 @@ mod tests { assert!(registry.build("no-such-format", &cfg).is_none()); } + #[test] + fn standard_registry_holds_every_production_format() { + let registry = FormatRegistry::standard(); + assert_eq!( + registry.names(), + vec!["head_to_head", "open_practice", "timed_qual", "zippyq",] + ); + assert!(registry.contains("open_practice")); + // The validation surface the server uses. + assert!(registry.contains("timed_qual")); + assert!(!registry.contains("knockout-demo")); + assert!(!registry.contains("no-such-format")); + // #218: ZippyQ stays **registered** even though it is shelved — so persisted `zippyq` rounds + // still build/validate/replay. It is only excluded from the *offered* set (see + // `standard_schemas_offer_every_format_except_shelved_zippyq`). + assert!(registry.contains("zippyq")); + } + + #[test] + fn standard_schemas_offer_every_format_except_shelved_zippyq() { + // The **offered** set (`GET /formats`, the Rounds UI's picker source) is a subset of the + // registered formats: ZippyQ is shelved (#218) so it is registered but NOT offered, which is + // what keeps it un-selectable for a new round while persisted `zippyq` rounds still load. + let schemas = FormatRegistry::standard_schemas(); + let offered: Vec<&str> = schemas.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(offered, vec!["head_to_head", "open_practice", "timed_qual"]); + // Head-to-Head carries group size + rotations (heats per group) + scoring; the points table + // is authored by the form editor. + let h2h = schemas.iter().find(|s| s.name == "head_to_head").unwrap(); + assert_eq!( + h2h.params + .iter() + .map(|p| p.key.as_str()) + .collect::>(), + vec!["group_size", "rotations", "scoring"] + ); + assert!( + !offered.contains(&"zippyq"), + "ZippyQ is shelved (#218) — must not be offered" + ); + // …yet it remains registered, so it's the offered set that shrank, not the registry. + assert!(FormatRegistry::standard().contains("zippyq")); + } + #[test] fn registry_built_rolling_uses_the_rounds_param() { let mut registry = FormatRegistry::new(); diff --git a/crates/engine/src/head_to_head.rs b/crates/engine/src/head_to_head.rs new file mode 100644 index 0000000..5980a43 --- /dev/null +++ b/crates/engine/src/head_to_head.rs @@ -0,0 +1,706 @@ +//! Head-to-Head — the **atomic racing format** (format-model.html, decision D17): split a field into +//! heats of `group_size`, race them, and rank the field. It is the building block every +//! tournament structure composes — a round-robin drives it all-play-all, a bracket chains it with +//! placement + winners-advance. +//! +//! # One grouping decision per round +//! +//! A Head-to-Head round makes its grouping decision **once**, at fill: the field splits into +//! consecutive groups of `group_size`. With `rotations` = 1 (the default) that single pass is the +//! whole round — everyone races exactly once. With `rotations` = N the **same groups** run back to +//! back N times (MultiGP ProSpec-style points racing: same pilots, same channels, run it again) and +//! the scoring accumulates. What a rotations knob deliberately does NOT do is re-group between +//! heats — the moment opponents change, something has to *decide* the new groups, and that is +//! seeding work, i.e. a tournament **structure** (round-robin all-play-all, a bracket's +//! winners-advance, …), not the atomic round. +//! +//! # Scoring +//! +//! - [`Scoring::Placement`] — rank by **finishing position** (all heat-winners first, then all +//! runners-up, …), breaking a band by laps. Over several rotations a pilot's **best single +//! result** counts. This is what a bracket reads (winners advance). +//! - [`Scoring::Points`] — each finish earns points from a **per-position table** (1st most, +//! descending; positions past the table earn 0), **summed across every heat flown**; rank by +//! total. With no explicit table the points fall back to the linear `heat_size − position + 1`. +//! This is what a round-robin sums — and what makes multi-rotation racing meaningful. +//! +//! For a single pass (one heat per pilot) Points and Placement rank alike; the table earns its keep +//! over many passes — a structure driving Head-to-Head repeatedly (round-robin), or this round's +//! own `rotations`. +#![forbid(unsafe_code)] + +use std::collections::{BTreeMap, BTreeSet}; + +use gridfpv_events::CompetitorRef; + +use crate::format::{ + CompletedHeat, FormatConfig, FormatRegistry, Generator, GeneratorStep, HeatPlan, RankEntry, + parse_points_table, position_points, rank_by, +}; + +/// How a Head-to-Head round turns its heats' finishes into a ranking. See the module docs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Scoring { + /// Rank by finishing position (band by place, then laps). What a bracket reads. + Placement, + /// Finishing-position points from a per-position table (`None` ⇒ linear `k − pos + 1`), summed. + Points(Option>), +} + +/// A Head-to-Head racing round over a seeded field. `next` emits the round's heats (the field split +/// into groups of [`group_size`](Self::group_size), the same groups repeated +/// [`rotations`](Self::rotations) times) then completes; `ranking` ranks the field under the +/// configured [`Scoring`]. +pub struct HeadToHead { + /// The field in seed/draw order (the recorded seeding already applied) — the split order and the + /// final ranking's deterministic tie-break. + field: Vec, + /// Pilots per heat (clamped to ≥ 2 — a head-to-head heat needs at least two to be a race). + group_size: usize, + /// How many times each group races (clamped to ≥ 1). The grouping is drawn once; every rotation + /// runs the **same** groups — see the module docs for why re-grouping is a structure's job. + rotations: usize, + /// How the round ranks its heats' finishes. + scoring: Scoring, +} + +impl HeadToHead { + /// The format name this registers under. + pub const NAME: &'static str = "head_to_head"; + + /// The default group size when unconfigured (head-to-head proper). + pub const DEFAULT_GROUP_SIZE: usize = 2; + + /// The default rotations when unconfigured — the classic single pass (everyone races once). + pub const DEFAULT_ROTATIONS: usize = 1; + + /// The most rotations a round accepts. `lay_out` materializes every rotation's heats up + /// front, so an unchecked param (`rotations=999999999` over the raw API) would allocate + /// unbounded memory at fill. 50 back-to-back heats per group is far beyond any real race + /// day; a request past it clamps here. + pub const MAX_ROTATIONS: usize = 50; + + /// Build over a `field` in seed order with the given group size (clamped to ≥ 2), rotations + /// (clamped to ≥ 1), and scoring. + pub fn new( + field: Vec, + group_size: usize, + rotations: usize, + scoring: Scoring, + ) -> Self { + Self { + field, + group_size: group_size.max(2), + rotations: rotations.clamp(1, Self::MAX_ROTATIONS), + scoring, + } + } + + /// The registry constructor: applies the recorded seeding draw, reads `group_size` (default 2), + /// `rotations` (default 1) and the `scoring` param (`points` ⇒ [`Scoring::Points`] reading the + /// `points` per-position table; anything else / absent ⇒ [`Scoring::Placement`]). + pub fn from_config(config: &FormatConfig) -> Box { + let field = config.seeding.apply(&config.field); + let group_size = config.param_usize("group_size", Self::DEFAULT_GROUP_SIZE); + let rotations = config.param_usize("rotations", Self::DEFAULT_ROTATIONS); + let scoring = match config.params.get("scoring").map(String::as_str) { + Some("points") => Scoring::Points(parse_points_table( + config.params.get("points").map(String::as_str), + )), + _ => Scoring::Placement, + }; + Box::new(Self::new(field, group_size, rotations, scoring)) + } + + /// Register this format under [`NAME`](Self::NAME). + pub fn register(registry: &mut FormatRegistry) { + registry.register(Self::NAME, Self::from_config); + } + + /// The heat id for heat `index` (0-based) of rotation `rotation` (0-based). A single-rotation + /// round keeps the historical `h2h-h{index}` ids (so persisted single-pass rounds keep + /// resolving); a multi-rotation round scopes them `h2h-r{rotation}-h{index}` (the `tq-r{n}-h{m}` + /// convention). + fn heat_id(&self, rotation: usize, index: usize) -> String { + if self.rotations == 1 { + format!("h2h-h{index}") + } else { + let r = rotation + 1; + format!("h2h-r{r}-h{index}") + } + } + + /// The field split into consecutive heats of `group_size` (the last possibly short), the same + /// groups repeated for each rotation in order (rotation 1's heats first). + fn lay_out(&self) -> Vec { + (0..self.rotations) + .flat_map(|rotation| { + self.field + .chunks(self.group_size) + .enumerate() + .map(move |(index, chunk)| (rotation, index, chunk.to_vec())) + }) + .map(|(rotation, index, chunk)| HeatPlan::new(self.heat_id(rotation, index), chunk)) + .collect() + } +} + +impl Generator for HeadToHead { + /// The **advancing set** a bracket carry (`FromHeatWinners`) reads: each completed heat's + /// **winner(s)** — in-heat position 1, ties included, a DQ never advances — in heat order, + /// de-duplicated across rotations (the same groups race again; one advancing slot per pilot). + /// + /// This OVERRIDES the default `ranking_advancers` ("everyone strictly better than the worst + /// ranking position"), which is wrong for a head-to-head level: the Placement ranking breaks + /// the losers' band by laps, giving losers of different heats *distinct* overall positions — + /// so the default advanced every loser but the single worst one (a losing semifinalist was + /// carried into the final). Winners-per-heat is the semantics the seeding doc promises + /// ("the source round's heat winners, in heat order"). + fn advancers(&self, completed: &[CompletedHeat]) -> Vec { + let mut seen: BTreeSet = BTreeSet::new(); + let mut advancing = Vec::new(); + for heat in completed { + for place in &heat.result.places { + if place.position == 1 && !place.disqualified { + let competitor = place.competitor.competitor.clone(); + if seen.insert(competitor.clone()) { + advancing.push(competitor); + } + } + } + } + advancing + } + + fn next(&mut self, completed: &[CompletedHeat]) -> GeneratorStep { + // A lone (or empty) field has nothing to race. Otherwise emit every rotation's heats up + // front (the grouping is fixed, so the whole schedule is known at fill — points racing + // schedules all heats up front, D17) and complete once they are all in. + if self.field.len() <= 1 { + return GeneratorStep::Complete; + } + let heats = self.lay_out(); + let done: BTreeSet<&str> = completed.iter().map(|c| c.heat.0.as_str()).collect(); + if heats.iter().all(|h| done.contains(h.heat.0.as_str())) { + GeneratorStep::Complete + } else { + GeneratorStep::Run(heats) + } + } + + fn ranking(&self, completed: &[CompletedHeat]) -> Vec { + // Before any heat, the provisional ranking is the seed order. + if completed.is_empty() { + return seed_ranking(&self.field); + } + + match &self.scoring { + // Points: sum each pilot's per-position points across the heats they flew; more = better, + // so negate into a smaller-is-better key. Seed every field member at 0 so a no-show still + // ranks (last). + Scoring::Points(table) => { + let mut totals: BTreeMap = + self.field.iter().map(|c| (c.clone(), 0)).collect(); + for heat in completed { + // The linear fallback prices a finish against the round's GROUP size, not the + // number of placements the heat happened to produce: a no-show shrinking the + // result must not devalue every present pilot's finish (a win in a 4-up group + // is worth 4 even if only 2 showed). An explicit table is unaffected (it looks + // up by position). A short *trailing* group prices by the same group size, so + // equal finishing positions earn equal points across a round's heats. + let heat_size = self.group_size; + for place in &heat.result.places { + // A DISQUALIFIED placement earns nothing: the DQ voids the finish that + // decided the position, so it must not pay points (mirrors timed_qual's + // DQ exclusion, #331). The pilot's other, clean heats still score. + if place.disqualified { + continue; + } + *totals + .entry(place.competitor.competitor.clone()) + .or_insert(0) += + position_points(table.as_deref(), place.position, heat_size); + } + } + let rows: Vec<(CompetitorRef, i64)> = + self.field.iter().map(|c| (c.clone(), -totals[c])).collect(); + rank_by(rows) + } + // Placement: band by finishing position (lower = better), breaking a band by laps (more = + // better → negated). A pilot who hasn't raced sorts last. Over several rotations (or if + // a pilot somehow has several heats) the best (position, then laps) counts — placement + // reads a pilot's best single result; accumulating across heats is Points' job. + Scoring::Placement => { + let mut best: BTreeMap = BTreeMap::new(); + for heat in completed { + for place in &heat.result.places { + // A DISQUALIFIED placement is no placement: it must not band the pilot + // (a DQ'd "win" is not a win a bracket may advance). DQ'd in every heat + // → no key → ranked last, the same place a no-show lands (#331). + if place.disqualified { + continue; + } + let key = (place.position, -(place.laps as i64)); + best.entry(place.competitor.competitor.clone()) + .and_modify(|k| { + if key < *k { + *k = key; + } + }) + .or_insert(key); + } + } + let rows: Vec<(CompetitorRef, (u32, i64))> = self + .field + .iter() + .map(|c| (c.clone(), best.get(c).copied().unwrap_or((u32::MAX, 0)))) + .collect(); + rank_by(rows) + } + } + } +} + +/// A trivial 1, 2, 3, … ranking from the seed order — the provisional ranking before any heat is run. +fn seed_ranking(order: &[CompetitorRef]) -> Vec { + order + .iter() + .enumerate() + .map(|(index, competitor)| RankEntry { + competitor: competitor.clone(), + position: (index as u32) + 1, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + 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() + } + 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), + ..Default::default() + }) + .collect(), + ..Default::default() + } + } + fn heat_ids(step: &GeneratorStep) -> Vec { + match step { + GeneratorStep::Run(heats) => heats.iter().map(|h| h.heat.0.clone()).collect(), + GeneratorStep::Complete => panic!("expected Run, got Complete"), + } + } + + #[test] + fn splits_the_field_into_heats_of_group_size() { + let mut g = HeadToHead::new(field(&["A", "B", "C", "D"]), 2, 1, Scoring::Placement); + assert_eq!(heat_ids(&g.next(&[])), vec!["h2h-h0", "h2h-h1"]); + // 4-up groups: one heat. + let mut g4 = HeadToHead::new(field(&["A", "B", "C", "D"]), 4, 1, Scoring::Placement); + assert_eq!(heat_ids(&g4.next(&[])), vec!["h2h-h0"]); + } + + #[test] + fn completes_once_its_single_pass_is_in() { + let mut g = HeadToHead::new(field(&["A", "B", "C", "D"]), 2, 1, Scoring::Placement); + let done = vec![ + CompletedHeat::new("h2h-h0", result(&[("A", 1, 5), ("B", 2, 4)])), + CompletedHeat::new("h2h-h1", result(&[("C", 1, 5), ("D", 2, 4)])), + ]; + assert_eq!(g.next(&done), GeneratorStep::Complete); + } + + #[test] + fn placement_bands_winners_first_then_by_laps() { + let g = HeadToHead::new(field(&["A", "B", "C", "D"]), 2, 1, Scoring::Placement); + // Heat 0: A wins (5 laps), B 2nd (4). Heat 1: C wins (6 laps), D 2nd (3). + let done = vec![ + CompletedHeat::new("h2h-h0", result(&[("A", 1, 5), ("B", 2, 4)])), + CompletedHeat::new("h2h-h1", result(&[("C", 1, 6), ("D", 2, 3)])), + ]; + // Winners band first, ordered by laps (C 6 > A 5); then runners-up (B 4 > D 3). + assert_eq!(names(&g.ranking(&done)), vec!["C", "A", "B", "D"]); + assert_eq!(g.ranking(&done)[0].position, 1); + } + + #[test] + fn points_use_a_custom_table_and_sum() { + // A custom table 10/6/3/1; one pass of two head-to-head heats. + let g = HeadToHead::new( + field(&["A", "B", "C", "D"]), + 2, + 1, + Scoring::Points(Some(vec![10, 6, 3, 1])), + ); + let done = vec![ + CompletedHeat::new("h2h-h0", result(&[("A", 1, 5), ("B", 2, 4)])), + CompletedHeat::new("h2h-h1", result(&[("C", 1, 6), ("D", 2, 3)])), + ]; + // Winners A, C earn 10 (tie → ref order A, C); runners-up B, D earn 6 (B, D). + assert_eq!(names(&g.ranking(&done)), vec!["A", "C", "B", "D"]); + assert_eq!(g.ranking(&done)[0].position, 1); + assert_eq!(g.ranking(&done)[2].position, 3); // the two winners share position 1 + } + + #[test] + fn points_fall_back_to_linear_without_a_table() { + let g = HeadToHead::new(field(&["A", "B", "C"]), 3, 1, Scoring::Points(None)); + // One 3-up heat: A 1st (3 pts), B 2nd (2), C 3rd (1). + let done = vec![CompletedHeat::new( + "h2h-h0", + result(&[("A", 1, 6), ("B", 2, 5), ("C", 3, 4)]), + )]; + assert_eq!(names(&g.ranking(&done)), vec!["A", "B", "C"]); + } + + #[test] + fn provisional_ranking_is_the_seed_order() { + let g = HeadToHead::new(field(&["A", "B", "C"]), 2, 1, Scoring::Placement); + assert_eq!(names(&g.ranking(&[])), vec!["A", "B", "C"]); + } + + #[test] + fn group_size_clamps_to_two() { + let g = HeadToHead::new(field(&["A", "B", "C", "D"]), 1, 1, Scoring::Placement); + assert_eq!(g.group_size, 2); + } + + #[test] + fn rotations_repeat_the_same_groups_with_rotation_scoped_ids() { + // 4 pilots, 2-up, 2 rotations: the SAME two groups run twice, rotation 1's heats first. + let mut g = HeadToHead::new(field(&["A", "B", "C", "D"]), 2, 2, Scoring::Points(None)); + let step = g.next(&[]); + assert_eq!( + heat_ids(&step), + vec!["h2h-r1-h0", "h2h-r1-h1", "h2h-r2-h0", "h2h-r2-h1"] + ); + // Every rotation's heat `index` carries the identical lineup (one grouping decision). + let GeneratorStep::Run(heats) = step else { + panic!("expected Run") + }; + assert_eq!(heats[0].lineup, heats[2].lineup); + assert_eq!(heats[1].lineup, heats[3].lineup); + } + + #[test] + fn rotations_clamp_to_one_and_keep_the_single_pass_ids() { + // rotations = 0 clamps to the classic single pass — historical `h2h-h{N}` ids preserved. + let mut g = HeadToHead::new(field(&["A", "B", "C", "D"]), 2, 0, Scoring::Placement); + assert_eq!(heat_ids(&g.next(&[])), vec!["h2h-h0", "h2h-h1"]); + } + + #[test] + fn completes_only_after_every_rotation_is_in() { + let mut g = HeadToHead::new(field(&["A", "B"]), 2, 2, Scoring::Points(None)); + // Rotation 1 done, rotation 2 outstanding → still Run. + let first = vec![CompletedHeat::new( + "h2h-r1-h0", + result(&[("A", 1, 5), ("B", 2, 4)]), + )]; + assert!(matches!(g.next(&first), GeneratorStep::Run(_))); + // Both rotations in → Complete. + let both = vec![ + CompletedHeat::new("h2h-r1-h0", result(&[("A", 1, 5), ("B", 2, 4)])), + CompletedHeat::new("h2h-r2-h0", result(&[("B", 1, 5), ("A", 2, 4)])), + ]; + assert_eq!(g.next(&both), GeneratorStep::Complete); + } + + #[test] + fn points_accumulate_across_rotations() { + // Two rotations of the same 2-up groups, linear points (1st = 2, 2nd = 1). + // A wins both of its heats (4 pts); C and D split theirs (3 pts each); B loses both (2). + let g = HeadToHead::new(field(&["A", "B", "C", "D"]), 2, 2, Scoring::Points(None)); + let done = vec![ + CompletedHeat::new("h2h-r1-h0", result(&[("A", 1, 5), ("B", 2, 4)])), + CompletedHeat::new("h2h-r1-h1", result(&[("C", 1, 5), ("D", 2, 4)])), + CompletedHeat::new("h2h-r2-h0", result(&[("A", 1, 5), ("B", 2, 4)])), + CompletedHeat::new("h2h-r2-h1", result(&[("D", 1, 5), ("C", 2, 4)])), + ]; + let ranking = g.ranking(&done); + assert_eq!(names(&ranking), vec!["A", "C", "D", "B"]); + assert_eq!(ranking[0].position, 1); + // C and D tie on 3 points and share position 2. + assert_eq!(ranking[1].position, 2); + assert_eq!(ranking[2].position, 2); + assert_eq!(ranking[3].position, 4); + } + + #[test] + fn placement_across_rotations_reads_the_best_single_result() { + // 2 rotations, placement scoring: B wins rotation 2, so B joins the winners band; the band + // orders by laps (A's winning 6 > B's winning 5). + let g = HeadToHead::new(field(&["A", "B"]), 2, 2, Scoring::Placement); + let done = vec![ + CompletedHeat::new("h2h-r1-h0", result(&[("A", 1, 6), ("B", 2, 5)])), + CompletedHeat::new("h2h-r2-h0", result(&[("B", 1, 5), ("A", 2, 4)])), + ]; + assert_eq!(names(&g.ranking(&done)), vec!["A", "B"]); + } + + /// Mark `name` disqualified in a built result — the adjudicated-DQ fixture (#331). + fn with_dq(mut result: HeatResult, name: &str) -> HeatResult { + for place in &mut result.places { + if place.competitor.competitor.0 == name { + place.disqualified = true; + } + } + result + } + + #[test] + fn points_skip_a_disqualified_placement() { + // A "wins" the heat but is DQ'd: the voided finish earns NOTHING, so B's earned + // runner-up point outranks A. Without the skip A would bank the winner's points off a + // finish the adjudication voided. + let g = HeadToHead::new(field(&["A", "B"]), 2, 1, Scoring::Points(None)); + let done = vec![CompletedHeat::new( + "h2h-h0", + with_dq(result(&[("A", 1, 5), ("B", 2, 4)]), "A"), + )]; + let ranking = g.ranking(&done); + assert_eq!(names(&ranking), vec!["B", "A"]); + assert_eq!(ranking[0].position, 1); + // A holds 0 points — strictly below B's 1, not tied (a tie would share position 1). + assert_eq!(ranking[1].position, 2, "the DQ'd win earns zero points"); + } + + #[test] + fn placement_skips_a_disqualified_placement() { + // Placement: a DQ'd "win" is no win — A gets no band key and ranks last (the same + // place a no-show lands), so a bracket reading the ranking can never advance the + // DQ'd winner. + let g = HeadToHead::new(field(&["A", "B"]), 2, 1, Scoring::Placement); + let done = vec![CompletedHeat::new( + "h2h-h0", + with_dq(result(&[("A", 1, 5), ("B", 2, 4)]), "A"), + )]; + assert_eq!(names(&g.ranking(&done)), vec!["B", "A"]); + } + + #[test] + fn rotations_clamp_to_max_rotations() { + // An unchecked `rotations=999` (raw API) clamps to MAX_ROTATIONS: the laid-out + // schedule is groups × MAX_ROTATIONS heats, not 999 rotations of unbounded memory. + let mut g = HeadToHead::new(field(&["A", "B", "C", "D"]), 2, 999, Scoring::Points(None)); + assert_eq!(g.rotations, HeadToHead::MAX_ROTATIONS); + assert_eq!( + heat_ids(&g.next(&[])).len(), + 2 * HeadToHead::MAX_ROTATIONS, + "2 groups × the clamped rotation cap" + ); + } + + #[test] + fn registry_reads_the_rotations_param() { + let mut registry = FormatRegistry::new(); + HeadToHead::register(&mut registry); + let cfg = FormatConfig::new(field(&["A", "B", "C", "D"])) + .with_param("group_size", "2") + .with_param("rotations", "3") + .with_param("scoring", "points"); + let mut g = registry.build(HeadToHead::NAME, &cfg).unwrap(); + // 2 groups × 3 rotations. + assert_eq!(heat_ids(&g.next(&[])).len(), 6); + } + + #[test] + fn points_linear_fallback_prices_by_group_size_not_result_size() { + // Two 4-up groups; heat 0's C and D no-show (only two placements land in the result). + // The linear fallback must price by the GROUP size: A's win earns 4 — the same as E's + // win in the full heat — not `places.len() − pos + 1 = 2`. Before the fix the shrunken + // result devalued heat 0 wholesale (E would outrank A off an identical win). + let g = HeadToHead::new( + field(&["A", "B", "C", "D", "E", "F", "G", "H"]), + 4, + 1, + Scoring::Points(None), + ); + let done = vec![ + CompletedHeat::new("h2h-h0", result(&[("A", 1, 5), ("B", 2, 4)])), + CompletedHeat::new( + "h2h-h1", + result(&[("E", 1, 5), ("F", 2, 4), ("G", 3, 3), ("H", 4, 2)]), + ), + ]; + let ranking = g.ranking(&done); + // A and E both earned 4 points and share position 1 (tie → ref order A, E); B and F + // both earned 3 and share position 3; G (2) and H (1) raced and earned theirs; the + // no-shows C and D sit on their seeded 0 points, last. + assert_eq!( + names(&ranking), + vec!["A", "E", "B", "F", "G", "H", "C", "D"] + ); + assert_eq!(ranking[0].position, 1, "A's 2-present win is a full win"); + assert_eq!(ranking[1].position, 1, "E ties A — same finishing position"); + assert_eq!(ranking[2].position, 3); + assert_eq!(ranking[3].position, 3); + } + + #[test] + fn odd_field_lays_out_a_short_trailing_group() { + // 5 pilots, 2-up: chunks() yields [2, 2, 1] — the trailing group is a SINGLE pilot. + // This pins the current layout: the 1-pilot heat IS emitted (a solo time-trial-style + // pass for the odd pilot out), not silently dropped or merged. + let mut g = HeadToHead::new(field(&["A", "B", "C", "D", "E"]), 2, 1, Scoring::Placement); + let step = g.next(&[]); + assert_eq!(heat_ids(&step), vec!["h2h-h0", "h2h-h1", "h2h-h2"]); + let GeneratorStep::Run(heats) = step else { + panic!("expected Run") + }; + assert_eq!(heats[0].lineup, field(&["A", "B"])); + assert_eq!(heats[1].lineup, field(&["C", "D"])); + assert_eq!(heats[2].lineup, field(&["E"]), "the odd pilot flies alone"); + } + + #[test] + fn odd_field_single_pilot_trailing_heat_ranks_under_both_scorings() { + // The [2, 2, 1] layout raced to completion: the solo pilot's unopposed "win" counts + // like any other heat win under BOTH scorings (pinning current behavior, not + // redesigning it — the RD who wants no solo heat picks a different group size). + let done = vec![ + CompletedHeat::new("h2h-h0", result(&[("A", 1, 5), ("B", 2, 4)])), + CompletedHeat::new("h2h-h1", result(&[("C", 1, 6), ("D", 2, 3)])), + CompletedHeat::new("h2h-h2", result(&[("E", 1, 4)])), + ]; + // Placement: E joins the winners band (position 1 in their heat), ordered within the + // band by laps — C (6) > A (5) > E (4) — then the runners-up B (4) > D (3). + let placement = + HeadToHead::new(field(&["A", "B", "C", "D", "E"]), 2, 1, Scoring::Placement); + assert_eq!( + names(&placement.ranking(&done)), + vec!["C", "A", "E", "B", "D"] + ); + // Points (linear, group_size 2): every heat winner earns 2 — including E's solo win — + // so A, C, E tie on 2 points at position 1 (ref order), B and D on 1 point at 4. + let points = HeadToHead::new( + field(&["A", "B", "C", "D", "E"]), + 2, + 1, + Scoring::Points(None), + ); + let ranking = points.ranking(&done); + assert_eq!(names(&ranking), vec!["A", "C", "E", "B", "D"]); + assert_eq!(ranking[0].position, 1); + assert_eq!( + ranking[2].position, 1, + "the solo win pays the same 2 points" + ); + assert_eq!(ranking[3].position, 4); + } + + #[test] + fn odd_field_short_trailing_group_scores_under_both_scorings() { + // 6 pilots, 4-up: chunks() yields [4, 2] — a short (but real) trailing group. + let done = vec![ + CompletedHeat::new( + "h2h-h0", + result(&[("A", 1, 6), ("B", 2, 5), ("C", 3, 4), ("D", 4, 3)]), + ), + CompletedHeat::new("h2h-h1", result(&[("E", 1, 5), ("F", 2, 4)])), + ]; + let laid_out = + |scoring| HeadToHead::new(field(&["A", "B", "C", "D", "E", "F"]), 4, 1, scoring); + let mut g = laid_out(Scoring::Placement); + let step = g.next(&[]); + assert_eq!(heat_ids(&step), vec!["h2h-h0", "h2h-h1"]); + let GeneratorStep::Run(heats) = step else { + panic!("expected Run") + }; + assert_eq!( + heats[1].lineup, + field(&["E", "F"]), + "the trailing group is the leftover 2" + ); + // Placement: winners band A (6 laps) > E (5), then the position-2 band B (5) > F (4), + // then C, D — the short group's finishes band exactly like the full group's. + assert_eq!(names(&g.ranking(&done)), vec!["A", "E", "B", "F", "C", "D"]); + // Points (linear, group_size 4): E's win in the short group earns the full 4 — equal + // finishing positions earn equal points across the round's heats — so A and E tie at + // position 1, B and F (3 points each) at 3, then C (2) and D (1). + let ranking = laid_out(Scoring::Points(None)).ranking(&done); + assert_eq!(names(&ranking), vec!["A", "E", "B", "F", "C", "D"]); + assert_eq!(ranking[0].position, 1); + assert_eq!( + ranking[1].position, 1, + "the short-group win pays the full group_size" + ); + assert_eq!(ranking[2].position, 3); + assert_eq!(ranking[3].position, 3); + assert_eq!(ranking[4].position, 5); + assert_eq!(ranking[5].position, 6); + } + + #[test] + fn advancers_carries_only_each_heats_winner_never_a_loser() { + // The verified bracket-carry bug: a 4-pilot 2-up level — A beats B (5 v 4 laps), + // C beats D (6 v 3). The Placement ranking breaks the losers' band by laps (B 3rd, + // D 4th), so the default position- Scheduled -//! Scheduled --> Staged : stage -//! Staged --> Armed : arm -//! Armed --> Running : start -//! Running --> Finished : time elapsed / all landed (finish) -//! Finished --> Scored : score -//! Scored --> [*] : advance -//! Staged --> Scheduled : abort -//! Armed --> Staged : abort -//! Running --> Staged : abort / restart -//! Scored --> Scheduled : discard & re-run +//! Scheduled --> Staged : stage +//! Staged --> Armed : start (runs the start procedure) +//! Armed --> Running : (auto) countdown elapsed / SkipCountdown (override) +//! Running --> Unofficial : (auto) win condition + grace / ForceEnd (override) +//! Unofficial --> Final : finalize +//! Final --> Unofficial : revert +//! Final --> [*] : advance +//! Staged --> Scheduled : abort +//! Armed --> Scheduled : abort +//! Running --> Scheduled : abort +//! Running --> Scheduled : restart +//! Armed --> Scheduled : restart +//! Unofficial --> Scheduled : restart +//! Unofficial --> Scheduled : discard & re-run +//! Final --> Scheduled : discard & re-run //! ``` //! +//! # The command collapse + the runtime clock (heat-lifecycle redesign, Slice 2) +//! +//! The two middle forward steps — `Armed → Running` and `Running → Unofficial` — are no +//! longer **manual** commands. They are **runtime-driven**: the Director's clock appends the +//! transition itself when the start countdown elapses (after a logged, deterministic delay) and +//! when the round's win condition + grace window are met. So the manual command set drops the old +//! `Start`/`Finish` (the engine *transitions* `Running`/`Finished` they produced still exist — +//! the runtime records them). The old `Arm` command is renamed **`Start`** (the RD presses +//! "Start", which arms the heat and runs the start procedure). +//! +//! For the race-day cases where the clock can't be trusted (a stuck countdown, a race that must be +//! called now), two **manual overrides** remain, legal only in the right state: +//! [`SkipCountdown`](HeatCommand::SkipCountdown) forces `Armed → Running` (skip the countdown) and +//! [`ForceEnd`](HeatCommand::ForceEnd) forces `Running → Unofficial` now. They record the same +//! `Running`/`Finished` transitions the auto-path does. +//! //! This module is **pure** (race-engine.html §6): it reads no clock and rolls no //! dice, so a recorded session always replays identically. Live race control is just //! [`HeatCommand`]s driven against the current [`HeatState`]; each legal command @@ -32,10 +53,12 @@ use std::fmt; use gridfpv_events::{Event, HeatId, HeatTransition}; +use serde::{Deserialize, Serialize}; /// The states of the heat loop (race-engine.html §2). `Scheduled` is the entry -/// state a [`Event::HeatScheduled`] creates; `Scored` is reached when the result is -/// finalized; `advance` leaves the machine (terminal for this heat). +/// state a [`Event::HeatScheduled`] creates; `Final` is reached when the result is +/// finalized (via the `Finalize` command); `advance` leaves the machine (terminal for +/// this heat). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HeatState { /// Created with a lineup, not yet staged (`[*] → Scheduled`). @@ -46,10 +69,11 @@ pub enum HeatState { Armed, /// The race is running; passes are consumed from here (plus the grace window). Running, - /// The race closed — time elapsed or all landed. - Finished, + /// The race closed — time elapsed or all landed — but the result is not yet + /// finalized (the grace window for late crossings is still open). + Unofficial, /// The result is finalized. - Scored, + Final, } /// The imperative commands of live race control (race-engine.html §2). A command is @@ -57,37 +81,53 @@ pub enum HeatState { /// the corresponding [`HeatTransition`]. Commands are kept distinct from the recorded /// transitions so the off-ramps stay legible: /// -/// | command | records | -/// |-----------|-------------------------------| -/// | `Stage` | [`HeatTransition::Staged`] | -/// | `Arm` | [`HeatTransition::Armed`] | -/// | `Start` | [`HeatTransition::Running`] | -/// | `Finish` | [`HeatTransition::Finished`] | -/// | `Score` | [`HeatTransition::Scored`] | -/// | `Advance` | [`HeatTransition::Advanced`] | -/// | `Abort` | [`HeatTransition::Aborted`] | -/// | `Restart` | [`HeatTransition::Restarted`] | -/// | `Discard` | [`HeatTransition::Discarded`] | +/// | command | records | +/// |-----------------|-------------------------------| +/// | `Stage` | [`HeatTransition::Staged`] | +/// | `Start` | [`HeatTransition::Armed`] | +/// | `SkipCountdown` | [`HeatTransition::Running`] | +/// | `ForceEnd` | [`HeatTransition::Finished`] | +/// | `Finalize` | [`HeatTransition::Finalized`] | +/// | `Advance` | [`HeatTransition::Advanced`] | +/// | `Revert` | [`HeatTransition::Reverted`] | +/// | `Abort` | [`HeatTransition::Aborted`] | +/// | `Restart` | [`HeatTransition::Restarted`] | +/// | `Discard` | [`HeatTransition::Discarded`] | +/// +/// The `Armed → Running` and `Running → Unofficial` transitions are normally appended by the +/// **runtime clock**, not an RD command (the command collapse, see the module docs). +/// [`SkipCountdown`](Self::SkipCountdown) / [`ForceEnd`](Self::ForceEnd) are the manual overrides +/// that record those same transitions when the clock must be bypassed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HeatCommand { /// Begin the countdown (Scheduled → Staged). Stage, - /// Open the gate to detections (Staged → Armed). - Arm, - /// Start the race (Armed → Running). + /// Start the heat (Staged → Armed) — opens the gate to detections and **runs the start + /// procedure**. The runtime then auto-advances to `Running` after the logged start delay. + /// (Renamed from the former `Arm` in the command collapse.) Start, - /// Close the race — time elapsed / all landed (Running → Finished). - Finish, - /// Finalize the result (Finished → Scored). - Score, - /// Hand results to the format generator (Scored → terminal). + /// **Override:** force `Armed → Running` immediately, skipping the start countdown — the + /// race-day escape hatch when the clock's auto-start can't be trusted. Records the same + /// [`HeatTransition::Running`] the runtime's auto-start would. + SkipCountdown, + /// **Override:** force `Running → Unofficial` now — call the race when the completion clock + /// must be bypassed. Records the same [`HeatTransition::Finished`] the runtime's auto-complete + /// would. + ForceEnd, + /// Finalize the result (Unofficial → Final). + Finalize, + /// Hand results to the format generator (Final → terminal). Advance, - /// Abandon before scoring — the target depends on the from-state - /// (Staged → Scheduled, Armed → Staged, Running → Staged). + /// Re-open a finalized result for correction (Final → Unofficial). + Revert, + /// Abandon before finalizing — always resets the heat to `Scheduled`, from any + /// abortable state (Staged/Armed/Running), so the RD re-Stages it. Abort, - /// Restart a running heat from staging (Running → Staged). + /// Restart a committed heat — always resets it to `Scheduled`, from any committed + /// state (Armed/Running/Unofficial), so the RD re-Stages it. Restart, - /// Discard a scored heat for a re-run (Scored → Scheduled). + /// Discard a raced heat for a re-run — throw the result away (Unofficial or Final → + /// Scheduled). Discard, } @@ -123,21 +163,31 @@ pub fn apply(state: HeatState, command: HeatCommand) -> Result HeatTransition::Staged, - (S::Staged, C::Arm) => HeatTransition::Armed, - (S::Armed, C::Start) => HeatTransition::Running, - (S::Running, C::Finish) => HeatTransition::Finished, - (S::Finished, C::Score) => HeatTransition::Scored, - (S::Scored, C::Advance) => HeatTransition::Advanced, - - // Off-ramps. Abort is legal from Staged/Armed/Running (it backs up a - // state); the landing state is resolved by `next_state`. + (S::Staged, C::Start) => HeatTransition::Armed, + (S::Unofficial, C::Finalize) => HeatTransition::Finalized, + (S::Final, C::Advance) => HeatTransition::Advanced, + + // Overrides for when the runtime clock can't be trusted (race-day escape hatches). They + // record exactly the transitions the auto-path would; legal only in the matching state. + (S::Armed, C::SkipCountdown) => HeatTransition::Running, + (S::Running, C::ForceEnd) => HeatTransition::Finished, + + // Off-ramps. Abort is legal from Staged/Armed/Running; it always resets the + // heat to `Scheduled` (see `next_state`), so the RD re-Stages it. (S::Staged | S::Armed | S::Running, C::Abort) => HeatTransition::Aborted, - // Restart applies only to a running heat (back to staging). - (S::Running, C::Restart) => HeatTransition::Restarted, - // Discard-and-re-run applies only to a scored heat. - (S::Scored, C::Discard) => HeatTransition::Discarded, + // Restart applies to any committed heat short of finalized (Armed/Running/ + // Unofficial); it always resets the heat to `Scheduled` (see `next_state`), so + // the RD re-Stages it for a clean re-run. + (S::Armed | S::Running | S::Unofficial, C::Restart) => HeatTransition::Restarted, + // Revert re-opens a finalized result for correction (Final → Unofficial). + (S::Final, C::Revert) => HeatTransition::Reverted, + // Discard-and-re-run applies to a raced heat (Unofficial or Final) — throw the + // result away and reset to Scheduled (see `next_state`) for a clean re-run. + (S::Unofficial | S::Final, C::Discard) => HeatTransition::Discarded, // Everything else is illegal in this state. _ => return Err(IllegalTransition { state, command }), @@ -147,17 +197,20 @@ pub fn apply(state: HeatState, command: HeatCommand) -> Result HeatState { +pub fn next_state(_from: HeatState, transition: HeatTransition) -> HeatState { use HeatState as S; use HeatTransition as T; @@ -165,17 +218,19 @@ pub fn next_state(from: HeatState, transition: HeatTransition) -> HeatState { T::Staged => S::Staged, T::Armed => S::Armed, T::Running => S::Running, - T::Finished => S::Finished, - T::Scored => S::Scored, - // Advance hands off to the format generator; the heat itself stays Scored + T::Finished => S::Unofficial, + T::Finalized => S::Final, + // Advance hands off to the format generator; the heat itself stays Final // (terminal). The state machine for this heat ends here. - T::Advanced => S::Scored, - // Abort backs up one state: Staged → Scheduled, Armed/Running → Staged. - T::Aborted => match from { - S::Staged => S::Scheduled, - _ => S::Staged, - }, - T::Restarted => S::Staged, + T::Advanced => S::Final, + // Revert re-opens a finalized result back to Unofficial for correction. + T::Reverted => S::Unofficial, + // Abort always resets the heat to Scheduled (from any abortable state), so the + // RD re-Stages it. + T::Aborted => S::Scheduled, + // Restart always resets the heat to Scheduled (from any committed state), so the + // RD re-Stages it — consistent with Abort. + T::Restarted => S::Scheduled, T::Discarded => S::Scheduled, } } @@ -216,19 +271,29 @@ pub fn heat_state<'a>( } /// The grace window for late crossings after a heat is finished (race-engine.html -/// §2): "late crossings still count until the heat is scored; the window is -/// configurable, default until scored". -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +/// §2): "late crossings still count until the heat is finalized; the window is +/// configurable, default until finalized". +/// +/// Derives serde + `ts_rs::TS` so it can be carried as a per-round config +/// ([`RoundDef::grace_window`](../../server/events/struct.RoundDef.html)) and read by the +/// frontend. The runtime completion clock (heat-lifecycle Slice 2) reads this to decide how long +/// to hold the heat in `Running` for trailing pilots after the win condition is met, before +/// appending the auto `Running → Unofficial`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "bindings/")] pub enum GraceWindow { - /// Late crossings count for the whole `Finished` phase, until the heat is - /// `Scored`. The default. + /// Late crossings count for the whole `Unofficial` phase, until the heat is + /// `Final`. The default for [`consumes_pass`] (an open window); a *completion-clock* round + /// config instead defaults to a bounded [`Duration`](Self::Duration) so the auto-transition + /// actually fires. #[default] UntilScored, /// Late crossings count only for `micros` microseconds after the heat finished; /// crossings later than that are not consumed even if the heat is still - /// `Finished`. + /// `Unofficial`. Duration { /// Length of the grace window, in microseconds on the source clock. + #[ts(type = "number")] micros: i64, }, } @@ -236,26 +301,26 @@ pub enum GraceWindow { /// Whether a pass should be consumed by this heat (race-engine.html §2). /// /// The rule: **passes are consumed only while the heat is `Running`, plus the grace -/// window after it `Finished`** — by default until the heat is `Scored`. +/// window after it is `Unofficial`** — by default until the heat is `Final`. /// /// Inputs: /// - `state` — the heat's current [`HeatState`]. /// - `grace` — the configured [`GraceWindow`]. /// - `since_finished_micros` — microseconds elapsed since the heat finished, on the /// source clock (`pass_time - finished_time`). Only consulted when `state` is -/// `Finished` and `grace` is [`GraceWindow::Duration`]. Pass `None` when the heat +/// `Unofficial` and `grace` is [`GraceWindow::Duration`]. Pass `None` when the heat /// has not finished (the value is irrelevant there); a negative value (a pass at or /// before the finish instant) is always within the window. /// /// Behaviour by state: /// - `Running` → `true` (the heat is live). -/// - `Finished` → `true` iff still within the grace window: -/// - [`GraceWindow::UntilScored`]: always `true` (the whole `Finished` phase). +/// - `Unofficial` → `true` iff still within the grace window: +/// - [`GraceWindow::UntilScored`]: always `true` (the whole `Unofficial` phase). /// - [`GraceWindow::Duration { micros }`]: `true` iff /// `since_finished_micros <= micros` (a `None` elapsed is treated as within the /// window, since the caller could not place the pass after finish). -/// - any other state (`Scheduled`, `Staged`, `Armed`, `Scored`) → `false`. In -/// particular, once `Scored` the window is closed regardless of `grace`. +/// - any other state (`Scheduled`, `Staged`, `Armed`, `Final`) → `false`. In +/// particular, once `Final` the window is closed regardless of `grace`. /// /// Pure: it derives consumption from the supplied values and reads no clock itself. pub fn consumes_pass( @@ -265,7 +330,7 @@ pub fn consumes_pass( ) -> bool { match state { HeatState::Running => true, - HeatState::Finished => match grace { + HeatState::Unofficial => match grace { GraceWindow::UntilScored => true, GraceWindow::Duration { micros } => { // Within the window when the elapsed time is unknown (caller could @@ -277,6 +342,63 @@ pub fn consumes_pass( } } +/// The **protest window** for the provisional → official lifecycle (marshaling Slice 5, +/// marshaling.html §3.3): an optional, OFF-by-default **auto-official timer**. +/// +/// A heat sits in [`Unofficial`](HeatState::Unofficial) (provisional, correctable) after the race +/// closes. When a protest window is configured, the runtime **auto-finalizes** it +/// (`Unofficial → Final`) once the window elapses from the race-end instant; the RD can always +/// finalize early (manually) or correct during the window, and [`Revert`](HeatCommand::Revert) +/// re-opens a finalized result. This is **not** a gate that blocks `Finalize` — it is an additive +/// auto-finalize. The default ([`Off`](Self::Off)) is today's behaviour: manual `Finalize` only. +/// +/// Derives serde + `ts_rs::TS` so it can be carried as a per-round config +/// ([`RoundDef::protest_window`](../../server/events/struct.RoundDef.html)) and read by the +/// frontend; it mirrors [`GraceWindow`]'s shape (an off variant + a bounded duration). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "bindings/")] +pub enum ProtestWindow { + /// No auto-official timer (the default): the heat stays `Unofficial` until the RD manually + /// finalizes it. Today's behaviour, unchanged. + #[default] + Off, + /// Auto-finalize `micros` microseconds after the race-end instant: once the window elapses the + /// runtime appends the `Finalize` the RD would have pressed. The RD may still finalize early. + After { + /// Length of the protest window, in microseconds (server wall clock), counted from the + /// `Running → Unofficial` race-end instant. + #[ts(type = "number")] + micros: i64, + }, +} + +/// Whether an `Unofficial` heat's auto-official timer is **due** (marshaling Slice 5): the protest +/// window has elapsed, so the runtime should append the auto `Finalize` (`Unofficial → Final`). +/// +/// Pure — like [`consumes_pass`], it reads no clock: the elapsed time is an **input**. The runtime +/// supplies `since_finished_micros` (now − the race-end instant) and emits the `Finalize` itself. +/// +/// Inputs: +/// - `state` — the heat's current [`HeatState`]. Only an `Unofficial` heat can auto-finalize. +/// - `window` — the configured [`ProtestWindow`]. [`Off`](ProtestWindow::Off) never fires. +/// - `since_finished_micros` — microseconds elapsed since the race-end instant (server clock). +/// `None` (the race-end instant is unknown / the heat has not finished) is never due. +/// +/// Returns `true` iff `state` is `Unofficial`, `window` is [`After`](ProtestWindow::After), and the +/// elapsed time is at least the window (inclusive — exactly at the deadline is due). +pub fn auto_official_due( + state: HeatState, + window: ProtestWindow, + since_finished_micros: Option, +) -> bool { + match (state, window) { + (HeatState::Unofficial, ProtestWindow::After { micros }) => { + since_finished_micros.is_some_and(|elapsed| elapsed >= micros) + } + _ => false, + } +} + #[cfg(test)] mod tests { use super::*; @@ -287,17 +409,18 @@ mod tests { HeatState::Staged, HeatState::Armed, HeatState::Running, - HeatState::Finished, - HeatState::Scored, + HeatState::Unofficial, + HeatState::Final, ]; - const ALL_COMMANDS: [HeatCommand; 9] = [ + const ALL_COMMANDS: [HeatCommand; 10] = [ HeatCommand::Stage, - HeatCommand::Arm, HeatCommand::Start, - HeatCommand::Finish, - HeatCommand::Score, + HeatCommand::SkipCountdown, + HeatCommand::ForceEnd, + HeatCommand::Finalize, HeatCommand::Advance, + HeatCommand::Revert, HeatCommand::Abort, HeatCommand::Restart, HeatCommand::Discard, @@ -310,19 +433,25 @@ mod tests { use HeatState as S; use HeatTransition as T; vec![ - // forward path + // forward path (the Armed→Running / Running→Unofficial steps are runtime-appended, + // not manual commands — see the overrides below) (S::Scheduled, C::Stage, T::Staged), - (S::Staged, C::Arm, T::Armed), - (S::Armed, C::Start, T::Running), - (S::Running, C::Finish, T::Finished), - (S::Finished, C::Score, T::Scored), - (S::Scored, C::Advance, T::Advanced), + (S::Staged, C::Start, T::Armed), + (S::Unofficial, C::Finalize, T::Finalized), + (S::Final, C::Advance, T::Advanced), + // overrides (manual route for the runtime-driven transitions) + (S::Armed, C::SkipCountdown, T::Running), + (S::Running, C::ForceEnd, T::Finished), // off-ramps (S::Staged, C::Abort, T::Aborted), (S::Armed, C::Abort, T::Aborted), (S::Running, C::Abort, T::Aborted), + (S::Armed, C::Restart, T::Restarted), (S::Running, C::Restart, T::Restarted), - (S::Scored, C::Discard, T::Discarded), + (S::Unofficial, C::Restart, T::Restarted), + (S::Final, C::Revert, T::Reverted), + (S::Unofficial, C::Discard, T::Discarded), + (S::Final, C::Discard, T::Discarded), ] } @@ -361,8 +490,27 @@ mod tests { #[test] fn legal_table_is_exhaustive_over_the_diagram() { - // 6 forward edges + 3 aborts + restart + discard = 11 legal pairs. - assert_eq!(legal_table().len(), 11); + // 4 manual forward edges + 2 overrides + 3 aborts + 3 restarts + revert + 2 discards = 15. + assert_eq!(legal_table().len(), 15); + } + + #[test] + fn overrides_are_legal_only_in_their_state() { + use HeatCommand as C; + use HeatState as S; + use HeatTransition as T; + // SkipCountdown forces Armed → Running; ForceEnd forces Running → Unofficial. + assert_eq!(apply(S::Armed, C::SkipCountdown), Ok(T::Running)); + assert_eq!(apply(S::Running, C::ForceEnd), Ok(T::Finished)); + // Each is illegal in every other state. + for &state in &ALL_STATES { + if state != S::Armed { + assert!(apply(state, C::SkipCountdown).is_err(), "{state:?} + Skip"); + } + if state != S::Running { + assert!(apply(state, C::ForceEnd).is_err(), "{state:?} + ForceEnd"); + } + } } #[test] @@ -372,30 +520,106 @@ mod tests { assert_eq!(next_state(S::Scheduled, T::Staged), S::Staged); assert_eq!(next_state(S::Staged, T::Armed), S::Armed); assert_eq!(next_state(S::Armed, T::Running), S::Running); - assert_eq!(next_state(S::Running, T::Finished), S::Finished); - assert_eq!(next_state(S::Finished, T::Scored), S::Scored); - // advance is terminal: the heat stays Scored. - assert_eq!(next_state(S::Scored, T::Advanced), S::Scored); + assert_eq!(next_state(S::Running, T::Finished), S::Unofficial); + assert_eq!(next_state(S::Unofficial, T::Finalized), S::Final); + // advance is terminal: the heat stays Final. + assert_eq!(next_state(S::Final, T::Advanced), S::Final); } #[test] - fn abort_target_depends_on_the_from_state() { + fn finalize_is_legal_only_from_unofficial() { + use HeatCommand as C; + use HeatState as S; + use HeatTransition as T; + assert_eq!(apply(S::Unofficial, C::Finalize), Ok(T::Finalized)); + for &state in &ALL_STATES { + if state != S::Unofficial { + assert!(apply(state, C::Finalize).is_err(), "{state:?} + Finalize"); + } + } + } + + #[test] + fn revert_is_legal_only_from_final_and_lands_unofficial() { + use HeatCommand as C; use HeatState as S; use HeatTransition as T; - // Staged → Scheduled + // Legal from Final, recording Reverted, landing back at Unofficial. + assert_eq!(apply(S::Final, C::Revert), Ok(T::Reverted)); + assert_eq!(next_state(S::Final, T::Reverted), S::Unofficial); + // Illegal everywhere else. + for &state in &ALL_STATES { + if state != S::Final { + assert!(apply(state, C::Revert).is_err(), "{state:?} + Revert"); + } + } + } + + #[test] + fn restart_is_legal_from_armed_running_unofficial_landing_scheduled() { + use HeatCommand as C; + use HeatState as S; + use HeatTransition as T; + for &state in &[S::Armed, S::Running, S::Unofficial] { + assert_eq!(apply(state, C::Restart), Ok(T::Restarted), "{state:?}"); + assert_eq!(next_state(state, T::Restarted), S::Scheduled, "{state:?}"); + } + // Not legal before the heat is committed, nor once finalized. + for &state in &[S::Scheduled, S::Staged, S::Final] { + assert!(apply(state, C::Restart).is_err(), "{state:?} + Restart"); + } + } + + #[test] + fn abort_always_resets_to_scheduled() { + use HeatState as S; + use HeatTransition as T; + // Abort always lands in Scheduled, from any abortable state, so the RD + // re-Stages the heat. assert_eq!(next_state(S::Staged, T::Aborted), S::Scheduled); - // Armed → Staged - assert_eq!(next_state(S::Armed, T::Aborted), S::Staged); - // Running → Staged - assert_eq!(next_state(S::Running, T::Aborted), S::Staged); + assert_eq!(next_state(S::Armed, T::Aborted), S::Scheduled); + assert_eq!(next_state(S::Running, T::Aborted), S::Scheduled); + } + + #[test] + fn discard_is_legal_from_unofficial_and_final_landing_scheduled() { + use HeatCommand as C; + use HeatState as S; + use HeatTransition as T; + // A raced heat (Unofficial or Final) can be thrown away; Discarded resets it to + // Scheduled for a clean re-run (the UI offers Discard in both states). + for &state in &[S::Unofficial, S::Final] { + assert_eq!(apply(state, C::Discard), Ok(T::Discarded), "{state:?}"); + assert_eq!(next_state(state, T::Discarded), S::Scheduled, "{state:?}"); + } + // Illegal before the heat has any result to throw away. + for &state in &[S::Scheduled, S::Staged, S::Armed, S::Running] { + assert!(apply(state, C::Discard).is_err(), "{state:?} + Discard"); + } } #[test] fn restart_and_discard_land_correctly() { use HeatState as S; use HeatTransition as T; - assert_eq!(next_state(S::Running, T::Restarted), S::Staged); - assert_eq!(next_state(S::Scored, T::Discarded), S::Scheduled); + assert_eq!(next_state(S::Running, T::Restarted), S::Scheduled); + assert_eq!(next_state(S::Final, T::Discarded), S::Scheduled); + } + + #[test] + fn revert_round_trips_unofficial_final_unofficial() { + // Finalize then Revert returns a finalized heat to Unofficial, and it can be + // finalized again — a full correction loop. + let mut state = HeatState::Unofficial; + let t = apply(state, HeatCommand::Finalize).expect("Unofficial → Final"); + state = next_state(state, t); + assert_eq!(state, HeatState::Final); + let t = apply(state, HeatCommand::Revert).expect("Final → Unofficial"); + state = next_state(state, t); + assert_eq!(state, HeatState::Unofficial); + let t = apply(state, HeatCommand::Finalize).expect("Unofficial → Final again"); + state = next_state(state, t); + assert_eq!(state, HeatState::Final); } #[test] @@ -404,11 +628,13 @@ mod tests { let mut state = HeatState::Scheduled; let path = [ (HeatCommand::Stage, HeatState::Staged), - (HeatCommand::Arm, HeatState::Armed), - (HeatCommand::Start, HeatState::Running), - (HeatCommand::Finish, HeatState::Finished), - (HeatCommand::Score, HeatState::Scored), - (HeatCommand::Advance, HeatState::Scored), + (HeatCommand::Start, HeatState::Armed), + // The Armed→Running / Running→Unofficial steps are runtime-driven; the overrides + // record the same transitions, so the forward path is driven by them here. + (HeatCommand::SkipCountdown, HeatState::Running), + (HeatCommand::ForceEnd, HeatState::Unofficial), + (HeatCommand::Finalize, HeatState::Final), + (HeatCommand::Advance, HeatState::Final), ]; for (command, expected) in path { let transition = apply(state, command).expect("legal on forward path"); @@ -428,6 +654,10 @@ mod tests { CompetitorRef("node-0".into()), CompetitorRef("node-1".into()), ], + class: None, + round: None, + frequencies: vec![], + label: None, } } @@ -452,20 +682,21 @@ mod tests { changed(HeatTransition::Armed), changed(HeatTransition::Running), changed(HeatTransition::Finished), - changed(HeatTransition::Scored), + changed(HeatTransition::Finalized), ]; - assert_eq!(heat_state(&events, &heat()), Some(HeatState::Scored)); + assert_eq!(heat_state(&events, &heat()), Some(HeatState::Final)); } #[test] fn heat_state_reconstructs_an_abort_and_re_run() { - // Stage, arm, run, abort (back to Staged), then re-arm and run on. + // Stage, arm, run, abort (back to Scheduled), then re-stage, re-arm and run on. let events = vec![ scheduled(), changed(HeatTransition::Staged), changed(HeatTransition::Armed), changed(HeatTransition::Running), - changed(HeatTransition::Aborted), // Running → Staged + changed(HeatTransition::Aborted), // Running → Scheduled + changed(HeatTransition::Staged), changed(HeatTransition::Armed), changed(HeatTransition::Running), ]; @@ -480,8 +711,8 @@ mod tests { changed(HeatTransition::Armed), changed(HeatTransition::Running), changed(HeatTransition::Finished), - changed(HeatTransition::Scored), - changed(HeatTransition::Discarded), // Scored → Scheduled + changed(HeatTransition::Finalized), + changed(HeatTransition::Discarded), // Final → Scheduled ]; assert_eq!(heat_state(&events, &heat()), Some(HeatState::Scheduled)); } @@ -494,6 +725,10 @@ mod tests { Event::HeatScheduled { heat: other.clone(), lineup: vec![], + class: None, + round: None, + frequencies: vec![], + label: None, }, changed(HeatTransition::Staged), Event::HeatStateChanged { @@ -519,7 +754,7 @@ mod tests { let first = heat_state(&events, &heat()); let second = heat_state(&events, &heat()); assert_eq!(first, second); - assert_eq!(first, Some(HeatState::Staged)); + assert_eq!(first, Some(HeatState::Scheduled)); } #[test] @@ -539,14 +774,14 @@ mod tests { #[test] fn grace_until_scored_consumes_while_finished() { assert!(consumes_pass( - HeatState::Finished, + HeatState::Unofficial, GraceWindow::UntilScored, None )); // Default is UntilScored. assert_eq!(GraceWindow::default(), GraceWindow::UntilScored); assert!(consumes_pass( - HeatState::Finished, + HeatState::Unofficial, GraceWindow::default(), Some(999_999_999), )); @@ -555,12 +790,12 @@ mod tests { #[test] fn grace_closed_once_scored() { assert!(!consumes_pass( - HeatState::Scored, + HeatState::Final, GraceWindow::UntilScored, None )); assert!(!consumes_pass( - HeatState::Scored, + HeatState::Final, GraceWindow::Duration { micros: 1_000_000 }, Some(0), )); @@ -570,15 +805,19 @@ mod tests { fn grace_duration_bounds_the_window_after_finished() { let grace = GraceWindow::Duration { micros: 2_000_000 }; // Within the window — consumed. - assert!(consumes_pass(HeatState::Finished, grace, Some(1_500_000))); + assert!(consumes_pass(HeatState::Unofficial, grace, Some(1_500_000))); // Exactly at the boundary — consumed (inclusive). - assert!(consumes_pass(HeatState::Finished, grace, Some(2_000_000))); + assert!(consumes_pass(HeatState::Unofficial, grace, Some(2_000_000))); // Past the window — not consumed. - assert!(!consumes_pass(HeatState::Finished, grace, Some(2_000_001))); + assert!(!consumes_pass( + HeatState::Unofficial, + grace, + Some(2_000_001) + )); // A pass at/before the finish instant — within the window. - assert!(consumes_pass(HeatState::Finished, grace, Some(-5))); + assert!(consumes_pass(HeatState::Unofficial, grace, Some(-5))); // Elapsed unknown — treated as within the window. - assert!(consumes_pass(HeatState::Finished, grace, None)); + assert!(consumes_pass(HeatState::Unofficial, grace, None)); } #[test] @@ -590,4 +829,58 @@ mod tests { ); } } + + // --- the auto-official timer (marshaling Slice 5) --------------------------------- + + #[test] + fn protest_window_off_never_auto_finalizes() { + // OFF = manual Finalize only (today's behaviour): no elapsed value ever makes it due. + for elapsed in [None, Some(-1), Some(0), Some(1_000_000), Some(i64::MAX)] { + assert!( + !auto_official_due(HeatState::Unofficial, ProtestWindow::Off, elapsed), + "Off must never be due (elapsed {elapsed:?})", + ); + } + } + + #[test] + fn protest_window_after_is_due_once_the_window_elapses() { + let window = ProtestWindow::After { micros: 2_000_000 }; + // Before the window — not due. + assert!(!auto_official_due( + HeatState::Unofficial, + window, + Some(1_999_999) + )); + // Exactly at the deadline — due (inclusive). + assert!(auto_official_due( + HeatState::Unofficial, + window, + Some(2_000_000) + )); + // Past the deadline — due. + assert!(auto_official_due( + HeatState::Unofficial, + window, + Some(2_500_000) + )); + // An unknown race-end instant is never due (the runtime can't place the deadline yet). + assert!(!auto_official_due(HeatState::Unofficial, window, None)); + } + + #[test] + fn auto_official_is_due_only_from_unofficial() { + let window = ProtestWindow::After { micros: 0 }; + // Even a zero-length window only fires from Unofficial; any other state is never due, + // so the timer can never skip the provisional phase or re-fire on a finalized heat. + for &state in &ALL_STATES { + let due = auto_official_due(state, window, Some(10_000_000)); + assert_eq!( + due, + state == HeatState::Unofficial, + "{state:?} auto-official due should be {}", + state == HeatState::Unofficial, + ); + } + } } diff --git a/crates/engine/src/imd.rs b/crates/engine/src/imd.rs new file mode 100644 index 0000000..7a48178 --- /dev/null +++ b/crates/engine/src/imd.rs @@ -0,0 +1,386 @@ +//! IMD-aware channel-set scoring + selection (#209 auto-pick). +//! +//! **IMD** (inter­modulation distortion) is the analog-video failure mode that decides which +//! *set* of channels flies cleanly together in a heat. When several VTX transmit at once their +//! signals mix in every receiver's front end and produce **third-order intermodulation +//! products** — new frequencies at `2·f_i − f_j` (two-tone) and `f_i + f_j − f_k` (three-tone). +//! When such a product lands on (or near) a frequency another pilot in the same heat is flying, +//! that pilot sees video breakup. IMD is therefore a property of a heat's **simultaneous** +//! lineup, not of the roster: it only matters for the channels flying *at the same time*. +//! +//! This module is the pure, deterministic core that the heat-build channel assignment uses to +//! pick the cleanest set: +//! +//! - [`imd_score`] scores a candidate channel set — higher is cleaner (the worst third-order +//! product is *farther* from every used channel). +//! - [`pick_best_imd_set`] chooses the size-`n` subset of the available channels that maximises +//! that score, with a deterministic tie-break so heat fill stays replay-deterministic. +//! +//! Frequencies are raw **MHz** (`u16`), the timer's `available_channels`. Products are computed +//! in `i32` because `2·f_i − f_j` can exceed `u16::MAX` or go negative. +#![forbid(unsafe_code)] + +/// Score a channel set by its **third-order IMD cleanliness** — higher is cleaner (#209). +/// +/// Computes every third-order intermodulation product among `freqs`: +/// +/// - **two-tone**, for every ordered pair `i ≠ j`: `2·f_i − f_j`; +/// - **three-tone**, for every distinct triple `(i, j, k)`: `f_i + f_j − f_k`. +/// +/// The score is the **minimum absolute gap (MHz)** between any such product and any frequency in +/// the set: the closest a spurious product comes to landing on a channel a pilot is actually +/// flying. A large minimum gap means even the worst product is comfortably away from every used +/// channel (clean); a small gap means a product sits on or beside a used channel (video +/// breakup). **Higher = cleaner.** +/// +/// Edge cases: a set of fewer than two frequencies produces no two-tone products and a set of +/// fewer than three produces no three-tone products; when there are no products at all the score +/// is [`i32::MAX`] (nothing can interfere). Math is `i32` throughout — a product can exceed +/// `u16::MAX` or go negative — and the gap to each (`u16`) used frequency is taken in `i32`. +pub fn imd_score(freqs: &[u16]) -> i32 { + let f: Vec = freqs.iter().map(|&x| i32::from(x)).collect(); + let n = f.len(); + + // The smallest absolute gap seen between any product and any used frequency. Starts at the + // "no interference possible" sentinel and only ever shrinks. + let mut min_gap = i32::MAX; + + // Smallest |product − used| over every used frequency, folded into `min_gap`. + let consider = |product: i32, min_gap: &mut i32| { + for &used in &f { + let gap = (product - used).abs(); + if gap < *min_gap { + *min_gap = gap; + } + } + }; + + // Two-tone third-order products: 2·f_i − f_j for every ordered pair i ≠ j. + for i in 0..n { + for j in 0..n { + if i != j { + consider(2 * f[i] - f[j], &mut min_gap); + } + } + } + + // Three-tone third-order products: f_i + f_j − f_k for every distinct triple (i, j, k). + for i in 0..n { + for j in 0..n { + for k in 0..n { + if i != j && j != k && i != k { + consider(f[i] + f[j] - f[k], &mut min_gap); + } + } + } + } + + min_gap +} + +/// The total spread (max − min MHz) of a frequency set — the tie-break preference: wider is +/// better (channels spread across more of the band). Empty/singleton sets spread `0`. +fn spread(freqs: &[u16]) -> i32 { + match (freqs.iter().min(), freqs.iter().max()) { + (Some(&lo), Some(&hi)) => i32::from(hi) - i32::from(lo), + _ => 0, + } +} + +/// Choose the size-`n` subset of `available` with the best (highest) [`imd_score`] (#209 auto-pick). +/// +/// Returns the cleanest `n` channels to fly simultaneously, sorted ascending (lowest channel +/// first) so the caller can pair them with a seed-ordered lineup deterministically. When +/// `n == 0` the result is empty; when `n >= available.len()` the whole (de-duplicated) pool is +/// returned (there is no choice to make). +/// +/// # Tractability +/// +/// FPV channel pools are small — a band is ≤ 8 channels and a timer rarely advertises more than +/// ~12 — so for `available.len() <= 12` this **enumerates every subset** of size `n` and keeps +/// the best, which is exact. For a larger pool the exhaustive count `C(len, n)` can blow up, so +/// it falls back to a **greedy** heuristic: start from the widest-spread seed pair and add, one +/// at a time, the channel that keeps the running set's score highest. The greedy result is not +/// guaranteed optimal but is good and fast; the cap is generous enough that the real fill always +/// takes the exact path. +/// +/// # Determinism (replay-safe) +/// +/// No clock, no RNG — pure over its inputs. Ties in `imd_score` are broken **deterministically**: +/// prefer the **widest total spread**, then the **lexicographically lowest** sorted channel set. +/// So the same `available` + `n` always yields the same subset, which is what keeps heat fill +/// replay-deterministic. +pub fn pick_best_imd_set(available: &[u16], n: usize) -> Vec { + // De-duplicate while preserving the first-seen order (a pool should not offer a channel + // twice; if it does, a subset never gets the same channel twice). + let mut pool: Vec = Vec::new(); + for &ch in available { + if !pool.contains(&ch) { + pool.push(ch); + } + } + + if n == 0 { + return Vec::new(); + } + if n >= pool.len() { + let mut all = pool; + all.sort_unstable(); + return all; + } + + /// Beyond this pool size the exhaustive enumeration is skipped for a greedy heuristic. 12 is + /// comfortably above the FPV norm (one band ≤ 8 channels; a flexible timer rarely lists more). + const EXHAUSTIVE_POOL_CAP: usize = 12; + + if pool.len() <= EXHAUSTIVE_POOL_CAP { + best_subset_exhaustive(&pool, n) + } else { + best_subset_greedy(&pool, n) + } +} + +/// Whether candidate set `a` is strictly **better** than the current best `b` under the full +/// ordering: higher [`imd_score`], then wider [`spread`], then lexicographically lower sorted set. +/// Both slices must already be sorted ascending. +fn is_better(a: &[u16], a_score: i32, b: &[u16], b_score: i32) -> bool { + a_score > b_score + || (a_score == b_score && (spread(a) > spread(b) || (spread(a) == spread(b) && a < b))) +} + +/// Exhaustively enumerate every size-`n` subset of `pool` and return the best under the IMD +/// score + deterministic tie-break. `pool` is de-duplicated; `0 < n < pool.len()`. +fn best_subset_exhaustive(pool: &[u16], n: usize) -> Vec { + let mut best: Option<(Vec, i32)> = None; + let mut indices: Vec = (0..n).collect(); + let len = pool.len(); + + loop { + // The current subset, sorted ascending (pool order is arbitrary; indices ascend so the + // gathered set is already sorted by pool order — we sort defensively for the tie-break). + let mut subset: Vec = indices.iter().map(|&i| pool[i]).collect(); + subset.sort_unstable(); + let score = imd_score(&subset); + + let take = match &best { + None => true, + Some((b, b_score)) => is_better(&subset, score, b, *b_score), + }; + if take { + best = Some((subset, score)); + } + + // Advance the combination indices (standard lexicographic next-combination). + let mut i = n; + loop { + if i == 0 { + // Exhausted every combination. + return best.map(|(s, _)| s).unwrap_or_default(); + } + i -= 1; + if indices[i] != i + len - n { + indices[i] += 1; + for j in (i + 1)..n { + indices[j] = indices[j - 1] + 1; + } + break; + } + } + } +} + +/// A greedy fallback for a pool larger than the exhaustive cap: seed with the widest-spread pair, +/// then add the channel that keeps the running [`imd_score`] highest (tie-break: widest spread, +/// then lowest channel). Not guaranteed optimal — only used for pools too large to enumerate. +fn best_subset_greedy(pool: &[u16], n: usize) -> Vec { + let mut sorted = pool.to_vec(); + sorted.sort_unstable(); + + if n == 1 { + // A single channel has no products and so no IMD distinction; the lowest is the + // deterministic pick. + return vec![sorted[0]]; + } + + // Seed with the widest-spread pair: the lowest and highest channels. + let mut chosen: Vec = vec![sorted[0], sorted[sorted.len() - 1]]; + + while chosen.len() < n { + let mut best: Option<(u16, i32)> = None; + for &cand in &sorted { + if chosen.contains(&cand) { + continue; + } + let mut trial = chosen.clone(); + trial.push(cand); + trial.sort_unstable(); + let score = imd_score(&trial); + + // Compare the candidate addition by the resulting set's score, then spread, then by + // preferring the lower candidate channel. + let take = match best { + None => true, + Some((best_cand, best_score)) => { + score > best_score || (score == best_score && cand < best_cand) + } + }; + if take { + best = Some((cand, score)); + } + } + match best { + Some((cand, _)) => chosen.push(cand), + None => break, + } + } + + chosen.sort_unstable(); + chosen +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The standard 5.8 GHz Raceband R1–R8, in channel order — an evenly-spaced (37 MHz step) + /// set, the IMD-clean reference. + const RACEBAND: [u16; 8] = [5658, 5695, 5732, 5769, 5806, 5843, 5880, 5917]; + + #[test] + fn evenly_spaced_set_scores_above_a_tightly_packed_set() { + // A worked clean-vs-tight comparison (#209). Take three Raceband channels spread across + // the band vs three tightly-packed adjacent channels. + // + // CLEAN — R1, R4, R8 = [5658, 5769, 5917]: + // two-tone 2·f_i − f_j and three-tone f_i+f_j−f_k land far from every used channel. + // e.g. 2·5769 − 5658 = 5880 (gap 37 to 5917/none-used), 5658+5917−5769 = 5806, etc. + // TIGHT — adjacent 5658, 5695, 5732 (37 MHz apart, consecutive): + // 2·5695 − 5658 = 5732 — *exactly* a used channel → gap 0. Catastrophic IMD. + let clean = [5658u16, 5769, 5917]; + let tight = [5658u16, 5695, 5732]; + + let clean_score = imd_score(&clean); + let tight_score = imd_score(&tight); + + // The tight, consecutive set has a product landing exactly on a used channel. + assert_eq!( + tight_score, 0, + "tight adjacent set: a product hits a channel" + ); + // The spread set keeps every product well away from every channel. + assert!( + clean_score > tight_score, + "evenly-spaced {clean:?} (score {clean_score}) must beat tight {tight:?} (score {tight_score})" + ); + assert!(clean_score > 0, "clean set has no product on a channel"); + } + + #[test] + fn raceband_full_set_score_is_zero_consecutive() { + // The full evenly-spaced Raceband is itself NOT IMD-clean as a simultaneous 8-set: with a + // constant 37 MHz step, 2·R2 − R1 = R3 exactly. This is why real 8-pilot events use a + // hand-tuned IMD set, and why picking a clean *subset* matters. + assert_eq!(imd_score(&RACEBAND), 0); + } + + #[test] + fn fewer_than_two_frequencies_have_no_products() { + // No pairs/triples ⇒ no products ⇒ the "no interference" sentinel. + assert_eq!(imd_score(&[]), i32::MAX); + assert_eq!(imd_score(&[5800]), i32::MAX); + } + + #[test] + fn score_is_symmetric_and_uses_i32_math() { + // A product can go below u16 range (negative) without panicking: 2·5658 − 5917 = 5399, + // still positive here, but the gap math is i32 so a low product never wraps. Two widely + // separated channels: 2·5658 − 5917 = 5399 (gap 259 to 5658); 2·5917 − 5658 = 6176 (gap + // 259 to 5917). Min gap 259. + let score = imd_score(&[5658, 5917]); + assert_eq!(score, 259); + // Order does not matter. + assert_eq!(imd_score(&[5917, 5658]), score); + } + + #[test] + fn pick_returns_the_max_scoring_subset_of_the_requested_size() { + // From the full Raceband pool, the best 3-channel subset must out-score the naive + // first-fit R1,R2,R3 (which scores 0 — a product lands on R3). + let picked = pick_best_imd_set(&RACEBAND, 3); + assert_eq!(picked.len(), 3, "exactly the requested size"); + + let first_fit = [5658u16, 5695, 5732]; + assert!( + imd_score(&picked) > imd_score(&first_fit), + "picked {picked:?} (score {}) must beat first-fit {first_fit:?} (score {})", + imd_score(&picked), + imd_score(&first_fit), + ); + // It is a genuine subset of the pool. + for ch in &picked { + assert!(RACEBAND.contains(ch), "{ch} is from the pool"); + } + } + + #[test] + fn pick_never_exceeds_available() { + // n larger than the pool returns the whole (sorted) pool, never invents channels. + let pool = [5658u16, 5917, 5732]; + let picked = pick_best_imd_set(&pool, 10); + assert_eq!(picked, vec![5658, 5732, 5917]); + } + + #[test] + fn pick_zero_is_empty() { + assert!(pick_best_imd_set(&RACEBAND, 0).is_empty()); + } + + #[test] + fn pick_is_deterministic_and_sorted() { + // Same inputs ⇒ same output, every time, sorted ascending. + let a = pick_best_imd_set(&RACEBAND, 4); + let b = pick_best_imd_set(&RACEBAND, 4); + assert_eq!(a, b, "deterministic"); + let mut sorted = a.clone(); + sorted.sort_unstable(); + assert_eq!(a, sorted, "result is sorted ascending"); + } + + #[test] + fn pick_dedupes_a_repeated_channel() { + // A pool listing a channel twice never yields it twice in the subset. + let pool = [5658u16, 5658, 5769, 5917, 5806]; + let picked = pick_best_imd_set(&pool, 3); + let mut deduped = picked.clone(); + deduped.sort_unstable(); + deduped.dedup(); + assert_eq!(picked.len(), deduped.len(), "no duplicate channel"); + } + + #[test] + fn tie_break_prefers_wider_spread_then_lower_channels() { + // Construct a pool where two size-2 subsets score identically on IMD (every 2-channel set + // has the same single min-gap pattern only when the gaps match). Use a symmetric pool so + // the picker must fall to the spread tie-break: pick the widest pair. + // Pool of 4 evenly spaced channels; the best 2-subset by spread is the outermost pair. + let pool = [5700u16, 5750, 5800, 5850]; + let picked = pick_best_imd_set(&pool, 2); + // The two widest-apart channels maximise spread; with equal IMD that is the tie-break. + assert_eq!(picked, vec![5700, 5850]); + } + + #[test] + fn greedy_path_runs_for_a_large_pool() { + // A pool above the exhaustive cap (13 channels) takes the greedy path and still returns a + // valid, sorted, in-pool subset of the requested size. + let pool: Vec = (0..13).map(|i| 5600 + i * 30).collect(); + let picked = pick_best_imd_set(&pool, 5); + assert_eq!(picked.len(), 5); + let mut sorted = picked.clone(); + sorted.sort_unstable(); + assert_eq!(picked, sorted, "sorted"); + for ch in &picked { + assert!(pool.contains(ch), "in pool"); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5b23756..4895d4f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,10 +10,11 @@ pub mod event; pub mod format; pub mod heat; +pub mod imd; pub mod schedule; pub mod scoring; // Concrete formats on the `format::Generator` interface (#33/#34/#35). -pub mod single_elim; +pub mod head_to_head; pub mod timed_qual; pub mod zippyq; 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 503446d..e008eac 100644 --- a/crates/engine/src/scoring.rs +++ b/crates/engine/src/scoring.rs @@ -39,12 +39,17 @@ //! 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; /// 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 @@ -55,6 +60,8 @@ pub enum WinCondition { /// of the last counted lap (whoever banked their final lap first). Timed { /// Window length in microseconds, measured from the race start. + /// Renders as a plain TS `number` (bounded far below 2^53). + #[ts(type = "number")] window_micros: i64, }, /// **First to N laps.** Rank by who completed lap `n` earliest. Competitors who @@ -86,7 +93,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, @@ -96,11 +104,58 @@ pub struct Placement { pub laps: u32, /// The condition-specific deciding metric for this competitor. pub metric: Metric, + /// The competitor's **fastest single completed lap** in this heat, in microseconds, or + /// `None` if they completed no timed lap. Computed from the run's lap durations + /// **independently of the win condition** (so e.g. a [`WinCondition::FirstToLaps`] + /// placement still carries a real best-lap, not the win metric), with thrown-out laps + /// excluded. Cross-heat rankings use it to break ties — round-robin breaks pilots equal + /// on points by their faster best lap. Defaults to `None` so older snapshots that + /// predate the field still deserialise. + #[serde(default)] + #[ts(type = "number | null")] + pub best_lap_micros: Option, + /// 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), + best_lap_micros: None, + disqualified: false, + } + } } /// 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. @@ -109,10 +164,10 @@ pub enum Metric { /// competitor never reached `n`. ReachedAt(Option), /// [`WinCondition::BestLap`]: fastest lap duration (µs), or `None` if no lap. - BestLapMicros(Option), + BestLapMicros(#[ts(type = "number | null")] Option), /// [`WinCondition::BestConsecutive`]: smallest sum (µs) of `n` consecutive laps, /// or `None` if fewer than `n` laps were completed. - BestConsecutiveMicros(Option), + BestConsecutiveMicros(#[ts(type = "number | null")] Option), } /// The scored heat: every competitor's [`Placement`], best position first. @@ -120,10 +175,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)] +#[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. @@ -133,6 +195,9 @@ struct ScoredLap { at: SourceTime, /// Lap duration in microseconds (`at - previous_pass.at`). duration_micros: i64, + /// The append offset of this lap's **end pass** — its stable identity, and the offset a + /// [`Event::LapThrownOut`](gridfpv_events::Event::LapThrownOut) targets to exclude it. + end_ref: u64, } /// One competitor's ordered completed laps, derived from their lap-gate passes. @@ -142,16 +207,43 @@ struct Run { } impl Run { - /// Build per-competitor runs from lap-gate passes: group by `(adapter, - /// competitor)`, order within a group, then each pass after the holeshot - /// completes a lap. - fn group(passes: &[Pass]) -> Vec { + /// Build per-competitor runs from `(offset, lap-gate pass)` pairs: group by `(adapter, + /// competitor)`, order within a group, then each pass after the holeshot completes a lap. Each + /// lap carries its **end pass offset** so a [`thrown-out`](Adjudications::is_thrown) lap can be + /// excluded by stable identity downstream. + /// + /// The offset is the pass's global append offset on the offset-aware paths + /// ([`score_with_global_offsets`] / [`apply_adjudications`]); the offset-free [`score`] entry + /// supplies synthetic positional offsets, which is harmless because throw-outs only arrive via + /// the offset-aware adjudicated paths. + fn group(passes: &[(u64, Pass)]) -> Vec { + // Same ordering rule as `gridfpv_projection::lap_list`: sequenced + // passes first (ascending sequence), then unsequenced, `at` last. + Self::group_ordered_by(passes, |p| (p.sequence.is_none(), p.sequence, p.at)) + } + + /// [`group`](Self::group) for a **corrected** pass stream (the output of + /// [`gridfpv_projection::corrected_passes`]), ordering each competitor's passes + /// **chronologically** — the projection's `corrected_order_key` rule. + /// + /// A corrected view is a single coherent timeline: a marshaling-inserted (or split-derived) + /// pass carries no `sequence`, so the raw sequence-first rule would sort a mid-timeline + /// insertion *after* every raw pass and derive a bogus (negative) lap. Ordering by `at` + /// (sequence only tie-breaking equal instants) makes the scored laps exactly the ones the + /// marshaling lap list shows — the corrected scorers must never disagree with it. + fn group_corrected(passes: &[(u64, Pass)]) -> Vec { + Self::group_ordered_by(passes, |p| (p.at, p.sequence.is_none(), p.sequence)) + } + + /// Shared grouping body: split `passes` by `(adapter, competitor)`, order each group by + /// `key`, and derive the per-competitor laps. + fn group_ordered_by(passes: &[(u64, Pass)], key: impl Fn(&Pass) -> K) -> Vec { use std::collections::BTreeMap; // BTreeMap keeps competitors in deterministic key order regardless of // arrival order — the same total-order tie-break the projection uses. - let mut by_competitor: BTreeMap> = BTreeMap::new(); - for pass in passes { + let mut by_competitor: BTreeMap> = BTreeMap::new(); + for (offset, pass) in passes { if pass.gate.is_lap_gate() { by_competitor .entry(CompetitorKey { @@ -159,27 +251,82 @@ impl Run { competitor: pass.competitor.clone(), }) .or_default() - .push(pass); + .push((*offset, pass)); } } by_competitor .into_iter() .map(|(competitor, mut group)| { - // Same ordering rule as `gridfpv_projection::lap_list`: sequenced - // passes first (ascending sequence), then unsequenced, `at` last. - group.sort_by_key(|p| (p.sequence.is_none(), p.sequence, p.at)); + group.sort_by_key(|(_, p)| key(p)); let laps = group .windows(2) .map(|pair| ScoredLap { - at: pair[1].at, - duration_micros: pair[1].at.micros_since(pair[0].at), + at: pair[1].1.at, + duration_micros: pair[1].1.at.micros_since(pair[0].1.at), + end_ref: pair[1].0, }) .collect(); Run { competitor, laps } }) .collect() } + + /// This competitor's **counted** laps — those not thrown out — in order. The single place the + /// throw-out exclusion is applied, so every win condition counts the same set. + fn counted<'a>(&'a self, adj: &'a Adjudications) -> Vec<&'a ScoredLap> { + self.laps + .iter() + .filter(|lap| !adj.is_thrown(lap.end_ref)) + .collect() + } +} + +/// Whether a heat's **race-end criterion is met** under `condition`, given its lap-gate +/// `passes` and the shared `race_start` (heat-lifecycle Slice 2). +/// +/// This is the pure predicate the Director's **runtime completion clock** evaluates each poll to +/// decide whether to begin the grace window and auto-append the `Running → Unofficial` transition. +/// Like [`score`], it reads **no clock and no RNG** — it derives the answer entirely from the +/// logged passes + the race start — so a replay reaches completion at the exact same point. +/// +/// The criterion per condition: +/// - [`WinCondition::Timed`]: met once a counted crossing lands **at or after** the window close +/// (`race_start + window_micros`). A pass at/after the cutoff is the observable signal that the +/// window has elapsed on the source clock; until one lands the window is still open. +/// - [`WinCondition::FirstToLaps`]: met once **any** competitor has completed `n` laps (the leader +/// reached the target). +/// - [`WinCondition::BestLap`] / [`WinCondition::BestConsecutive`] (qualifying): there is no +/// lap/leader criterion intrinsic to the passes — a qual session ends on its **time window**, +/// which these conditions do not carry. This predicate returns `false` for them; such rounds end +/// via the RD's [`ForceEnd`](crate::heat::HeatCommand::ForceEnd) override (or a Timed-bounded +/// qual). This keeps the function total and pure without inventing a window the condition lacks. +/// +/// `passes` may be the partial mid-heat list (the runtime calls it on the running passes); it is +/// grouped/ordered internally exactly as [`score`] does. +pub fn race_end_reached(passes: &[Pass], condition: WinCondition, race_start: SourceTime) -> bool { + match condition { + WinCondition::Timed { window_micros } => { + let cutoff = race_start.micros.saturating_add(window_micros); + passes + .iter() + .any(|p| p.gate.is_lap_gate() && p.at.micros >= cutoff) + } + WinCondition::FirstToLaps { n } => { + if n == 0 { + return true; + } + // A competitor reaches `n` laps after `n + 1` lap-gate crossings (the holeshot opens + // the count). Reuse the scorer's grouping so the lap model matches the ranking exactly. + // `race_end_reached` runs on the *live* pass stream (no adjudications yet), so positional + // offsets suffice — it only counts laps, never resolves a throw-out. + Run::group(&with_positional_offsets(passes)) + .iter() + .any(|run| run.laps.len() as u32 >= n) + } + // Qualifying conditions carry no intrinsic end criterion — see the doc above. + WinCondition::BestLap | WinCondition::BestConsecutive { .. } => false, + } } /// Score a heat from its lap-gate passes under `condition`. @@ -193,137 +340,483 @@ 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( + Run::group(&with_positional_offsets(passes)), + condition, + race_start, + &Adjudications::default(), + ) +} + +/// Pair each pass with its **positional** index as a synthetic offset — the offset-free entry +/// points' bridge to the offset-aware [`Run::group`]. Safe for the non-adjudicated paths because a +/// throw-out (the only ruling that reads a lap's offset) only ever arrives through the +/// offset-aware paths that thread the *true* global offsets. +fn with_positional_offsets(passes: &[Pass]) -> Vec<(u64, Pass)> { + passes + .iter() + .enumerate() + .map(|(i, p)| (i as u64, p.clone())) + .collect() +} + +/// The adjudications a heat's [`Event::PenaltyApplied`] / [`Event::HeatVoided`] log +/// distils to (with any [`Event::RulingReversed`] un-applying a targeted penalty), 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, + /// Lap-gate pass **offsets** whose lap was [`thrown out`](Event::LapThrownOut): the lap + /// *ending* at one of these offsets is a real lap but is excluded from the scored count + /// (marshaling.html §3.3). A set, so the exclusion is a pure, order-independent membership + /// test (the throw-out determinism risk, marshaling-plan.html §4). + thrown_out: 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`] / [`Event::RulingReversed`]; + /// `TimeAdded` penalties accumulate, any `Disqualify` disqualifies, any `HeatVoided` + /// voids. Deterministic — pure fold (no clock / RNG), order-independent in effect. + /// + /// A [`Event::RulingReversed { target }`](Event::RulingReversed) **un-applies** the + /// penalty at the `target` offset: a reversed `Disqualify` no longer disqualifies and a + /// reversed `TimeAdded` is excluded from the accumulation — so "DQ reversed" reads as a + /// first-class undo rather than overloading the lap-level void. The offset is the event's + /// slice index (the storage layer assigns dense append offsets; the scorer is fed the + /// heat window in append order, the same convention `corrected_passes` uses). Reversals + /// are gathered first so they apply regardless of whether they precede or follow the + /// penalty in the slice, keeping the fold order-independent. + /// Collect adjudications from `(global offset, event)` pairs. + /// + /// A [`Event::RulingReversed { target }`] is matched against the **global append offset** the + /// ruling was logged at — the same offset a [`LogRef`](gridfpv_events::LogRef) command carries + /// and the audit/lap projections expose (#55). The caller MUST feed the true global offsets (not + /// a re-enumerated window), or a reversal targets the wrong ruling: the result snapshot threads + /// the heat window's preserved global offsets exactly for this reason. + /// + /// **Generalized reversal (Slice 6):** a reversal un-applies *any* targeted ruling — a + /// [`PenaltyApplied`](Event::PenaltyApplied) (DQ / time; points are standings-only and folded + /// elsewhere), a [`LapThrownOut`](Event::LapThrownOut), or a [`HeatVoided`](Event::HeatVoided) — + /// keyed purely on the target's offset, so the throw-out / void / penalty all undo through the + /// one structural mechanism. + fn collect<'a, I>(events: I) -> Self + where + I: IntoIterator, + { + // Materialize once so we can scan reversals first, then apply (order-independent). + let events: Vec<(u64, &Event)> = events.into_iter().collect(); + // First, the offsets every `RulingReversed` targets — a ruling at one of these is + // un-applied. Gathered first so a reversal applies whether it precedes or follows its + // target in the slice, keeping the fold order-independent. + let reversed: BTreeSet = events + .iter() + .filter_map(|(_, e)| match e { + Event::RulingReversed { target } => Some(target.0), + _ => None, + }) + .collect(); + + let mut adj = Adjudications::default(); + for (offset, event) in events { + // A ruling whose own append offset was reversed is dropped from the result. + if reversed.contains(&offset) { + continue; + } + 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; + } + // Points penalties are **standings-only** (marshaling.html §3.3): they never + // touch the per-heat lap result, so the heat scorer ignores them here. The + // standings projection (`class_standings`) folds them instead. + Penalty::PointsDeducted { .. } | Penalty::PointsAdded { .. } => {} + }, + // A thrown-out lap: record its end-pass offset; the per-condition scorers exclude + // the lap ending there from the counted set (order-independent set membership). + Event::LapThrownOut { target } => { + adj.thrown_out.insert(target.0); + } + 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) + } + + /// Whether the lap whose **end pass** is at append offset `end_ref` was thrown out — excluded + /// from the scored count (a pure, order-independent membership test). + fn is_thrown(&self, end_ref: u64) -> bool { + self.thrown_out.contains(&end_ref) } } -/// 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( + runs: Vec, + condition: WinCondition, + race_start: SourceTime, + adj: &Adjudications, +) -> HeatResult { + 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, ) -> HeatResult { - let passes: Vec = events + // Positional offsets: callers of this entry point pass the *full* log (or a window that is the + // whole log), so the slice index equals the global append offset. The snapshot's windowed path + // uses [`score_with_global_offsets`] instead, which carries the real global offsets so a + // `RulingReversed` matches the right penalty (#55). + score_with_global_offsets( + events.iter().enumerate().map(|(i, e)| (i as u64, e)), + condition, + race_start, + ) +} + +/// Score a heat's events under `condition`, applying adjudications keyed on the **global append +/// offset** each event carries (#55). +/// +/// This is the offset-correct sibling of [`score_with_adjudications`]: it is fed `(global_offset, +/// &Event)` pairs (e.g. a heat window that preserved its global offsets), so a +/// [`Event::RulingReversed`] un-applies the penalty at its true global `target` — not a +/// window-relative position. The result snapshot uses this so a UI reversal hits the right ruling. +pub fn score_with_global_offsets<'a, I>( + events: I, + condition: WinCondition, + race_start: SourceTime, +) -> HeatResult +where + I: IntoIterator, +{ + let events: Vec<(u64, &Event)> = events.into_iter().collect(); + // Keep each lap-gate pass paired with its **global** offset, so a `LapThrownOut` targeting that + // pass's offset excludes the right lap (the throw-out's target is the lap's end-pass offset). + let passes: Vec<(u64, Pass)> = events .iter() - .filter_map(|e| match e { - Event::Pass(p) if p.gate.is_lap_gate() => Some(p.clone()), + .filter_map(|(o, e)| match e { + Event::Pass(p) if p.gate.is_lap_gate() => Some((*o, p.clone())), _ => None, }) .collect(); - score(&passes, condition, race_start) + score_inner( + Run::group(&passes), + condition, + race_start, + &Adjudications::collect(events.iter().map(|(o, e)| (*o, *e))), + ) +} + +/// Score an already-corrected pass stream under `condition`, folding the adjudications carried +/// by `(global offset, event)` pairs — the **offset-correct** sibling of +/// [`apply_adjudications`], for callers holding a heat *window* whose events keep their global +/// append offsets (a positional re-enumeration would make a [`Event::RulingReversed`] target +/// the wrong ruling, #55). +/// +/// This is the composed marshaled-and-adjudicated scorer a windowed caller wants: feed it +/// [`gridfpv_projection::corrected_passes`] over the same pairs (void / insert / adjust / +/// split folded into the pass stream) and it applies DQ / time / throw-out / heat-void on +/// top — so lap corrections and adjudications BOTH land in the result. +pub fn score_corrected_with_global_offsets<'a, I>( + passes: &[(u64, Pass)], + condition: WinCondition, + race_start: SourceTime, + events: I, +) -> HeatResult +where + I: IntoIterator, +{ + score_inner( + // Corrected-stream ordering: an inserted / re-timed pass slots in chronologically, + // exactly as the marshaling lap list orders it. + Run::group_corrected(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: &[(u64, Pass)], + condition: WinCondition, + race_start: SourceTime, + events: &[Event], +) -> HeatResult { + // Positional offsets — `score_marshaled` passes the full heat log, so slice index == global + // append offset (the same convention `corrected_passes` uses for the marshaling fold). The + // `passes` carry each corrected pass's addressable offset (its `end_ref`), so a `LapThrownOut` + // targeting that offset excludes the matching lap from the count. + score_inner( + // Corrected-stream ordering — see `score_corrected_with_global_offsets`. + Run::group_corrected(passes), + condition, + race_start, + &Adjudications::collect(events.iter().enumerate().map(|(i, e)| (i as u64, e))), + ) +} + +/// 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, Option, 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, Option, K)> = rows + .into_iter() + .map(|(competitor, laps, metric, best_lap_micros, key)| { + ( + adj.is_dq(&competitor.competitor), + competitor, + laps, + metric, + best_lap_micros, + 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.5.cmp(&b.5)) + .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, best_lap_micros, 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, + best_lap_micros, + disqualified, }); } - HeatResult { places } + HeatResult { + places, + voided: false, + } +} + +/// The competitor's **fastest single completed lap** (smallest duration, µs) among `laps`, or +/// `None` if they completed none. Win-condition-independent: every per-condition scorer reports +/// it on its [`Placement`] so a cross-heat ranking can break ties on best lap regardless of how +/// the heat was won. `laps` are the **counted** laps (thrown-out laps already excluded), so a +/// thrown-out lap can never be a competitor's best lap. +fn fastest_lap_micros(laps: &[&ScoredLap]) -> Option { + laps.iter().map(|lap| lap.duration_micros).min() } /// 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 { - let cutoff = race_start.micros + window_micros; +/// +/// `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.saturating_add(window_micros); let rows = runs .into_iter() .map(|run| { // HARD cutoff: strictly-before. A lap completing exactly at the cutoff - // (or after) does not count — no finishing the in-progress lap. - let counted: Vec<&ScoredLap> = run - .laps - .iter() + // (or after) does not count — no finishing the in-progress lap. Thrown-out laps are + // already excluded by `counted` before the cutoff filter. + let all_counted = run.counted(adj); + // Best single lap is win-condition-independent: a lap the competitor actually flew is + // a real lap even if it landed outside the timed window, so it is taken over every + // counted lap, not just the windowed ones. + let best_lap = fastest_lap_micros(&all_counted); + let counted: Vec<&ScoredLap> = all_counted + .into_iter() .filter(|lap| lap.at.micros < cutoff) .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) + ( + run.competitor, + count, + Metric::LastLapAt(last_at), + best_lap, + 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| { - let count = run.laps.len() as u32; + // Score the **counted** laps (thrown-out laps excluded) — so a throw-out can drop a + // reacher below `n`, exactly as if the lap had not been flown for scoring purposes. + let laps = run.counted(adj); + let count = laps.len() as u32; + // Best single lap, independent of the first-to-N win metric (which is a reach-time). + let best_lap = fastest_lap_micros(&laps); // `n` laps means the n-th completed lap (1-based) — index n-1. let reached_at = if n >= 1 && count >= n { - Some(run.laps[(n - 1) as usize].at) + Some(laps[(n - 1) as usize].at) } else { None }; - let last_at = run.laps.last().map(|lap| lap.at); + let last_at = 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) + ( + run.competitor, + count, + Metric::ReachedAt(reached_at), + best_lap, + 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| { - let count = run.laps.len() as u32; + // A thrown-out lap is not eligible to be a competitor's best lap. + let laps = run.counted(adj); + let count = laps.len() as u32; // Fastest lap = smallest duration; tie-break on the earlier-set one. - let best = run - .laps + let best = laps .iter() .min_by(|a, b| { a.duration_micros @@ -332,36 +825,49 @@ 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), ); ( run.competitor, count, Metric::BestLapMicros(best_micros), + // The win metric here *is* the fastest single lap, so report it directly. + best_micros, key, ) }) .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() .map(|run| { - let count = run.laps.len() as u32; + // Consecutive windows are over the **counted** laps — a thrown-out lap breaks the run + // of consecutiveness (it is excluded, not skipped-over), matching the lap-count model. + let laps = run.counted(adj); + let count = laps.len() as u32; + // Best single lap, independent of the consecutive-sum win metric. + let best_lap = fastest_lap_micros(&laps); // Slide an `n`-wide window over the laps; pick the smallest sum, tie- // broken by the earlier window-end (last lap's completion time). - let best = run - .laps + let best = laps .windows(n) .map(|w| { let sum: i64 = w.iter().map(|lap| lap.duration_micros).sum(); @@ -370,27 +876,29 @@ 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)), }; ( run.competitor, count, Metric::BestConsecutiveMicros(best_sum), + best_lap, key, ) }) .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, LogRef}; const ADAPTER: &str = "vd"; @@ -403,6 +911,7 @@ mod tests { sequence: Some(seq), gate: GateIndex::LAP, signal: None, + heat: None, } } @@ -577,6 +1086,53 @@ mod tests { assert_eq!(place(&r, "C").metric, Metric::ReachedAt(None)); } + // --- best_lap_micros (win-condition-independent fastest single lap) ------ + + #[test] + fn first_to_laps_placement_carries_fastest_single_lap_not_the_win_metric() { + // A's laps: 3s, 2s, 4s ⇒ fastest single lap 2s. The win metric is ReachedAt (a time), + // so best_lap_micros must be the 2s lap duration, independent of the win condition. + let passes = run("A", &[0, 3_000_000, 5_000_000, 9_000_000]); + let r = score(&passes, WinCondition::FirstToLaps { n: 3 }, start()); + assert_eq!(place(&r, "A").best_lap_micros, Some(2_000_000)); + // And the win metric is still the reach-time, not the best lap. + assert_eq!( + place(&r, "A").metric, + Metric::ReachedAt(Some(SourceTime::from_micros(9_000_000))) + ); + } + + #[test] + fn timed_placement_carries_fastest_single_lap_even_outside_the_window() { + // Window 10s. A's laps complete at 5s (dur 5s) and 12s (dur 7s); the 2nd lands past the + // cutoff so it does not COUNT, but it is a real flown lap. Fastest single lap is still the + // 5s one. (Here the windowed lap is also the fastest, but the point is best lap is taken + // over every counted lap, not just the windowed ones.) + let passes = run("A", &[0, 5_000_000, 12_000_000]); + let r = score( + &passes, + WinCondition::Timed { + window_micros: 10_000_000, + }, + start(), + ); + assert_eq!(place(&r, "A").laps, 1, "only the windowed lap counts"); + assert_eq!( + place(&r, "A").best_lap_micros, + Some(5_000_000), + "best lap is the fastest of every flown lap" + ); + } + + #[test] + fn placement_with_no_completed_lap_has_no_best_lap() { + // Z has a single pass (no completed lap) ⇒ best_lap_micros is None under any condition. + let passes = run("Z", &[1_000_000]); + let r = score(&passes, WinCondition::FirstToLaps { n: 1 }, start()); + assert_eq!(place(&r, "Z").best_lap_micros, None); + assert_eq!(place(&r, "Z").laps, 0); + } + // --- BestLap ------------------------------------------------------------ #[test] @@ -788,4 +1344,520 @@ mod tests { let r = score(&passes, WinCondition::BestLap, start()); assert_eq!(r.places.len(), 2); } + + // --- race_end_reached (heat-lifecycle Slice 2 completion predicate) ----- + + #[test] + fn timed_race_end_reached_when_a_pass_lands_at_or_after_the_cutoff() { + let cond = WinCondition::Timed { + window_micros: 10_000_000, + }; + // All passes strictly before the cutoff (10s): not yet reached. + let early = run("A", &[0, 5_000_000, 9_000_000]); + assert!(!race_end_reached(&early, cond, start())); + // A pass exactly at the cutoff signals the window has elapsed: reached. + let at_cutoff = run("A", &[0, 5_000_000, 10_000_000]); + assert!(race_end_reached(&at_cutoff, cond, start())); + // …and well after, too. + let after = run("A", &[0, 5_000_000, 12_000_000]); + assert!(race_end_reached(&after, cond, start())); + } + + #[test] + fn timed_cutoff_saturates_instead_of_overflowing() { + // P2: `race_start.micros + window_micros` would overflow (panic in debug) at the extremes; + // a saturating add clamps to `i64::MAX` so the predicate stays total. A near-max start plus + // a large window must not panic — and no real pass lands at/after the saturated cutoff. + let cond = WinCondition::Timed { + window_micros: i64::MAX, + }; + let near_max = SourceTime::from_micros(i64::MAX - 1); + let passes = run("A", &[0, 5_000_000, 9_000_000]); + assert!(!race_end_reached(&passes, cond, near_max)); + } + + #[test] + fn first_to_laps_race_end_reached_when_leader_hits_n() { + let cond = WinCondition::FirstToLaps { n: 3 }; + // 3 crossings ⇒ 2 laps: not yet. + let two = run("A", &[0, 3_000_000, 6_000_000]); + assert!(!race_end_reached(&two, cond, start())); + // 4 crossings ⇒ 3 laps: the leader reached the target. + let three = run("A", &[0, 3_000_000, 6_000_000, 9_000_000]); + assert!(race_end_reached(&three, cond, start())); + } + + #[test] + fn qualifying_conditions_have_no_intrinsic_race_end() { + // BestLap / BestConsecutive end on a time window the condition does not carry, so the + // predicate is always false (the RD ForceEnds, or a Timed-bounded qual is used). + let passes = run("A", &[0, 2_000_000, 4_000_000, 6_000_000]); + assert!(!race_end_reached(&passes, WinCondition::BestLap, start())); + assert!(!race_end_reached( + &passes, + WinCondition::BestConsecutive { n: 2 }, + start() + )); + } + + // --- 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 { reason: None })); + + 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 { reason: None })); + events.push(penalty("B", Penalty::Disqualify { reason: None })); + + 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 { reason: None })); + + 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); + } + + // --- RulingReversed (Slice 2): un-apply a targeted penalty ----------------- + + #[test] + fn ruling_reversed_unapplies_a_dq() { + // A leads on track (3 laps), B has 2. DQ A — A sinks to last — then REVERSE that DQ: + // A is restored to the lead and no longer flagged disqualified. + use gridfpv_events::LogRef; + + let mut events = pass_events("A", &[0, 3_000_000, 6_000_000, 9_000_000]); // offsets 0..3 + events.extend(pass_events("B", &[0, 5_000_000, 10_000_000])); // offsets 4..6 + let dq_offset = events.len() as u64; // offset 7 + events.push(penalty("A", Penalty::Disqualify { reason: None })); // offset 7 + events.push(Event::RulingReversed { + target: LogRef(dq_offset), + }); // offset 8 — reverse the DQ + + let r = score_events( + &events, + WinCondition::Timed { + window_micros: 60_000_000, + }, + start(), + ); + // The DQ is un-applied: A leads and is not flagged. + assert_eq!(place(&r, "A").position, 1); + assert!(!place(&r, "A").disqualified); + assert_eq!(place(&r, "B").position, 2); + } + + #[test] + fn ruling_reversed_targets_the_global_offset_not_a_window_position() { + // The load-bearing #55 offset fix in the scoring path: when scoring a heat WINDOW (events + // that start at a non-zero global offset), a `RulingReversed` must match the penalty by its + // GLOBAL offset, not by the window-relative index. We score via `score_with_global_offsets` + // feeding offsets that start at 100 — the DQ at global 107, reversed by targeting 107. + use gridfpv_events::LogRef; + + let mut events = pass_events("A", &[0, 3_000_000, 6_000_000, 9_000_000]); // window idx 0..3 + events.extend(pass_events("B", &[0, 5_000_000, 10_000_000])); // window idx 4..6 + events.push(penalty("A", Penalty::Disqualify { reason: None })); // window idx 7 → GLOBAL 107 + events.push(Event::RulingReversed { + target: LogRef(107), // the DQ's GLOBAL offset, NOT window index 7 + }); + + // Tag with global offsets 100.. (as a heat window that preserved them would). + let r = score_with_global_offsets( + events.iter().enumerate().map(|(i, e)| (100 + i as u64, e)), + WinCondition::Timed { + window_micros: 60_000_000, + }, + start(), + ); + // The reversal hit the right penalty: A is restored to the lead, not flagged. + assert_eq!(place(&r, "A").position, 1); + assert!(!place(&r, "A").disqualified); + + // Sanity: targeting the *window-relative* offset (7) would NOT reverse the global-107 DQ. + let mut wrong = pass_events("A", &[0, 3_000_000, 6_000_000, 9_000_000]); + wrong.extend(pass_events("B", &[0, 5_000_000, 10_000_000])); + wrong.push(penalty("A", Penalty::Disqualify { reason: None })); + wrong.push(Event::RulingReversed { target: LogRef(7) }); // wrong: a window index + let r2 = score_with_global_offsets( + wrong.iter().enumerate().map(|(i, e)| (100 + i as u64, e)), + WinCondition::Timed { + window_micros: 60_000_000, + }, + start(), + ); + assert!( + place(&r2, "A").disqualified, + "a window-relative target must NOT reverse the global-offset DQ" + ); + } + + #[test] + fn ruling_reversed_unapplies_a_time_penalty() { + // BestLap: A 2.0s, B 1.5s. A +1.0s time penalty would let B win; reversing it + // restores A's raw 2.0s deciding time? No — B is faster, so reversal lets B win on + // its raw lap. Concretely: with the penalty, A(2.0) beats B(1.5+1.0=2.5); reversed, + // B(1.5) beats A(2.0). The reversal flips the order — proof it was un-applied. + use gridfpv_events::LogRef; + + let mut events = pass_events("A", &[0, 2_000_000]); // offsets 0..1 + events.extend(pass_events("B", &[0, 1_500_000])); // offsets 2..3 + let pen_offset = events.len() as u64; // offset 4 + events.push(penalty("B", Penalty::TimeAdded { micros: 1_000_000 })); // offset 4 + + // With the penalty in force, A wins. + let with_pen = score_events(&events, WinCondition::BestLap, start()); + assert_eq!(place(&with_pen, "A").position, 1); + + // Reverse B's time penalty: B's raw 1.5s now wins. + events.push(Event::RulingReversed { + target: LogRef(pen_offset), + }); // offset 5 + let reversed = score_events(&events, WinCondition::BestLap, start()); + assert_eq!(place(&reversed, "B").position, 1); + assert_eq!(place(&reversed, "A").position, 2); + } + + #[test] + fn ruling_reversed_is_order_independent_and_deterministic() { + // The fold gathers reversals first, so a reversal that PRECEDES its penalty in the + // slice un-applies it just the same — and scoring the same log twice is identical + // (no clock / RNG; the scorer-purity invariant holds with reversals folded in). + use gridfpv_events::LogRef; + + // Reversal at offset 0, the penalty it targets at offset 1 — reversal comes first. + let mut events: Vec = vec![Event::RulingReversed { target: LogRef(1) }]; + events.push(penalty("A", Penalty::Disqualify { reason: None })); // offset 1 — reversed by offset 0 + events.extend(pass_events("A", &[0, 3_000_000, 6_000_000])); // A: 2 laps + events.extend(pass_events("B", &[0, 5_000_000])); // B: 1 lap + + let cond = WinCondition::Timed { + window_micros: 60_000_000, + }; + let first = score_events(&events, cond, start()); + let second = score_events(&events, cond, start()); + assert_eq!(first, second, "scoring with a reversal replays identically"); + // The DQ was un-applied despite the reversal preceding it: A leads, not flagged. + assert_eq!(place(&first, "A").position, 1); + assert!(!place(&first, "A").disqualified); + } + + // --- LapThrownOut (Slice 6): exclude a valid lap from the scored count ----------------------- + + #[test] + fn lap_thrown_out_excludes_a_valid_lap_from_the_count() { + // A flies 3 laps (4 passes at offsets 0..3); throw out A's 2nd lap (the lap ENDING at the + // pass at offset 2). A's counted laps drop 3 → 2, but the lap stays a real lap on track. + let mut events = pass_events("A", &[0, 3_000_000, 6_000_000, 9_000_000]); // offsets 0..3 + events.push(Event::LapThrownOut { target: LogRef(2) }); // throw out lap ending at offset 2 + + let cond = WinCondition::Timed { + window_micros: 60_000_000, + }; + let r = score_events(&events, cond, start()); + assert_eq!( + place(&r, "A").laps, + 2, + "the thrown-out lap is excluded from the counted set" + ); + } + + #[test] + fn lap_thrown_out_recompute_is_order_independent() { + // Determinism risk (marshaling-plan §4): the thrown-out set is a pure membership test, so + // the order the throw-out appears in the log cannot change the result. Fold the same facts + // in two different orders and assert identical scores. + let cond = WinCondition::Timed { + window_micros: 60_000_000, + }; + + // Order 1: passes then the throw-out. + let mut a = pass_events("A", &[0, 3_000_000, 6_000_000, 9_000_000]); + a.push(Event::LapThrownOut { target: LogRef(2) }); + let ra = score_events(&a, cond, start()); + + // Order 2: the throw-out FIRST (offset 0), then the passes (offsets 1..4). The target must + // move to the new end-pass offset (4) — the throw-out is keyed on the lap's end-pass offset. + let mut b: Vec = vec![Event::LapThrownOut { target: LogRef(4) }]; + b.extend(pass_events("A", &[0, 3_000_000, 6_000_000, 9_000_000])); // offsets 1..4 + let rb = score_events(&b, cond, start()); + + assert_eq!(place(&ra, "A").laps, 2); + assert_eq!(place(&rb, "A").laps, 2); + // Folding either twice is identical (no clock/RNG); the scorer stays pure. + assert_eq!(score_events(&a, cond, start()), ra); + assert_eq!(score_events(&b, cond, start()), rb); + } + + #[test] + fn lap_thrown_out_reorders_a_first_to_laps_result() { + // First-to-3: A reaches lap 3 at 9.0s, B at 10.0s → A wins. Throw out A's lap-2 (ending at + // offset 2): A now has only 2 *counted* laps and never reaches 3, so B (who reached 3) wins. + let mut events = pass_events("A", &[0, 3_000_000, 6_000_000, 9_000_000]); // offsets 0..3 + events.extend(pass_events("B", &[0, 4_000_000, 7_000_000, 10_000_000])); // offsets 4..7 + events.push(Event::LapThrownOut { target: LogRef(2) }); // throw out A's lap ending at offset 2 + + let r = score_events(&events, WinCondition::FirstToLaps { n: 3 }, start()); + assert_eq!( + place(&r, "B").position, + 1, + "B reached 3 counted laps; A did not" + ); + assert_eq!(place(&r, "A").position, 2); + assert_eq!(place(&r, "A").laps, 2); + } + + #[test] + fn lap_thrown_out_is_reversible() { + // Generalized reversal (Slice 6): reversing the `LapThrownOut` restores the excluded lap. + let mut events = pass_events("A", &[0, 3_000_000, 6_000_000, 9_000_000]); // offsets 0..3 + let throw_offset = events.len() as u64; // offset 4 + events.push(Event::LapThrownOut { target: LogRef(2) }); // offset 4 + let cond = WinCondition::Timed { + window_micros: 60_000_000, + }; + assert_eq!(place(&score_events(&events, cond, start()), "A").laps, 2); + + events.push(Event::RulingReversed { + target: LogRef(throw_offset), + }); // reverse the throw-out + assert_eq!( + place(&score_events(&events, cond, start()), "A").laps, + 3, + "reversing the throw-out restores the lap" + ); + } + + #[test] + fn dq_time_and_throwout_stack_predictably() { + // All three per-heat adjudications compose on one log, deterministically. Timed window. + // A: 3 laps, throw out one → 2 counted. B: 3 laps, +big time penalty (worsens tie-break). + // C: 3 laps clean. D: 3 laps but DQ'd. Expected order: C (clean 3), A (2 counted), + // B (3 but penalty-worsened tie-break still beats A's lower count), then D (DQ) last. + // Concretely we assert the DQ sinks D last, the throw-out drops A's count, and the result + // is identical across two runs (purity). + let mut events = pass_events("A", &[0, 5_000_000, 10_000_000, 15_000_000]); // 0..3, 3 laps + events.extend(pass_events("B", &[0, 5_000_000, 10_000_000, 15_000_000])); // 4..7, 3 laps + events.extend(pass_events("C", &[0, 5_000_000, 10_000_000, 15_000_000])); // 8..11, 3 laps + events.extend(pass_events("D", &[0, 5_000_000, 10_000_000, 15_000_000])); // 12..15, 3 laps + events.push(Event::LapThrownOut { target: LogRef(3) }); // throw A's last lap → A: 2 counted + events.push(penalty("B", Penalty::TimeAdded { micros: 50_000_000 })); // worsen B's tie-break + events.push(penalty("D", Penalty::Disqualify { reason: None })); // DQ D + + let cond = WinCondition::Timed { + window_micros: 60_000_000, + }; + let r = score_events(&events, cond, start()); + assert_eq!(place(&r, "A").laps, 2, "A lost a lap to the throw-out"); + assert_eq!(place(&r, "C").laps, 3); + assert!(place(&r, "D").disqualified, "D is DQ'd"); + // The DQ sinks D below everyone non-DQ'd. + let d_pos = place(&r, "D").position; + for p in &r.places { + if !p.disqualified { + assert!(p.position < d_pos); + } + } + // C (clean 3 laps) leads. + assert_eq!(place(&r, "C").position, 1); + // Deterministic on replay. + assert_eq!(score_events(&events, cond, start()), r); + } + + #[test] + fn points_penalties_do_not_touch_the_heat_result() { + // PointsDeducted / PointsAdded are standings-only (folded by `class_standings`): the per-heat + // scorer must ignore them, so a heat result with a points penalty equals the clean result. + let mut with_points = pass_events("A", &[0, 3_000_000, 6_000_000]); + with_points.extend(pass_events("B", &[0, 4_000_000])); + let clean = with_points.clone(); + with_points.push(penalty("A", Penalty::PointsDeducted { points: 10 })); + with_points.push(penalty("B", Penalty::PointsAdded { points: 5 })); + + let cond = WinCondition::Timed { + window_micros: 60_000_000, + }; + assert_eq!( + score_events(&with_points, cond, start()), + score_events(&clean, cond, start()), + "points penalties leave the per-heat result unchanged" + ); + } } diff --git a/crates/engine/src/single_elim.rs b/crates/engine/src/single_elim.rs deleted file mode 100644 index fc78de4..0000000 --- a/crates/engine/src/single_elim.rs +++ /dev/null @@ -1,593 +0,0 @@ -//! Single-elimination bracket generator (#34) — a [`Generator`] that seeds a field -//! into a knockout bracket and advances winners round by round until one remains. -//! -//! # The format (race-engine.html §3, §5) -//! -//! A single-elimination bracket is the archetypal **fixed-but-state-driven** format -//! (RE §3): the whole bracket is determined by the seeded field, yet every round is -//! still derived from the results so far — `next` reads the completed heats, takes -//! each heat's winner(s), and lays out the next round (RE §5, "winners advance toward -//! a final"). It never reads a clock or an RNG: any seeding draw is resolved once and -//! injected as a [`SeedingOutcome`] at construction (RE §6), so the bracket replays -//! identically. -//! -//! # Seeding & round layout -//! -//! 1. **Seed the field.** The config carries the field in seed order (best first); a -//! recorded [`SeedingOutcome`] is applied first (identity if none), giving the -//! draw order the bracket actually uses. -//! 2. **Pair strong-vs-weak.** [`bracket_pairs`] reorders the seeds `[1, 8, 2, 7, …]` -//! so consecutive entries are match-ups (1 v 8, 2 v 7, …) — the standard bracket -//! seeding that keeps top seeds apart until late rounds. -//! 3. **Chunk into heats.** The bracket order is split into heats of `heat_size` -//! competitors (default **2**, i.e. head-to-head; set `heat_size=4` for 4-up -//! heats). Each heat advances its **top half** (`heat_size / 2`, at least one): -//! head-to-head advances the winner; a 4-up heat advances its top two. -//! -//! # Byes (odd / short fields) -//! -//! When the bracket order does not divide evenly into full heats, the **trailing** -//! competitors form a short final chunk. A chunk that ends up with a *single* -//! competitor is a **bye**: that competitor advances to the next round without flying -//! a heat, deterministically. Because [`bracket_pairs`] places an odd field's middle -//! seed last, the bye naturally falls to that seed (e.g. a 5-field head-to-head lays -//! out `1 v 5`, `2 v 4`, and `3` byes). A short chunk that still has two or more -//! competitors is run as a smaller heat (e.g. a 6-field 4-up round runs a 4-up heat -//! and a 2-up heat). -//! -//! # Ranking -//! -//! [`Generator::ranking`] is the **bracket placement**: the eventual winner first, the -//! runner-up (whoever lost the final) second, then everyone else ordered by **how far -//! they advanced** — competitors eliminated in a later round outrank those knocked out -//! earlier. Within a round, finishers are ordered by the [`HeatResult`] that knocked -//! them out (better in-heat position first), 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 single-elimination bracket over a seeded field. -/// -/// Constructed with the seeded field (draw order already applied) and a `heat_size`; -/// `next` drives the rounds and `ranking` reports the bracket placement. See the -/// module docs for the seeding / bye / advancement rules. -pub struct SingleElim { - /// The field in seed/draw order (the recorded [`SeedingOutcome`] already applied). - field: Vec, - /// Competitors per heat (default 2 = head-to-head). Each heat advances its top - /// half (`heat_size / 2`, at least one). - heat_size: usize, -} - -impl SingleElim { - /// The format name this registers under. - pub const NAME: &'static str = "single_elim"; - - /// Build over a `field` in seed order with the given `heat_size` (clamped to a - /// minimum of 2 — a heat needs at least two competitors to eliminate anyone). - pub fn new(field: Vec, heat_size: usize) -> Self { - Self { - field, - heat_size: heat_size.max(2), - } - } - - /// The registry constructor: applies the recorded `seeding` draw to `field` and - /// reads the optional `heat_size` param (default 2 = head-to-head). - pub fn from_config(config: &FormatConfig) -> Box { - let field = config.seeding.apply(&config.field); - let heat_size = config.param_usize("heat_size", 2); - Box::new(Self::new(field, heat_size)) - } - - /// Register this format under [`NAME`](Self::NAME). - pub fn register(registry: &mut FormatRegistry) { - registry.register(Self::NAME, Self::from_config); - } - - /// How many competitors advance out of a heat of `n` competitors: the top half, - /// at least one. (Head-to-head → 1; 4-up → 2; a short 3-up chunk → 1.) - fn advance_count(&self, n: usize) -> usize { - (self.heat_size / 2).min(n.saturating_sub(1)).max(1).min(n) - } - - /// The heat id for heat `index` (0-based) of round `round` (1-based). - fn heat_id(round: usize, index: usize) -> String { - format!("se-r{round}-h{index}") - } - - /// Lay `order` (a round's bracket order) into heats of `heat_size`, returning the - /// heat plans **and** the byes (single-competitor chunks that advance for free). - /// - /// A trailing chunk with two or more competitors is a (possibly short) heat; a - /// trailing chunk of exactly one is a bye. The split is deterministic: chunks are - /// taken left-to-right in `order`. - fn lay_out_round( - &self, - round: usize, - order: &[CompetitorRef], - ) -> (Vec, Vec) { - let mut heats = Vec::new(); - let mut byes = Vec::new(); - for (index, chunk) in order.chunks(self.heat_size).enumerate() { - if chunk.len() == 1 { - byes.push(chunk[0].clone()); - } else { - heats.push(HeatPlan::new(Self::heat_id(round, index), chunk.to_vec())); - } - } - (heats, byes) - } - - /// Replay the bracket from the completed history, returning, for each round that - /// has *fully* completed, the competitors advancing out of it (in bracket order), - /// plus the bracket order of the round currently awaiting heats (if any). - /// - /// This is the pure core both [`next`](Generator::next) and - /// [`ranking`](Generator::ranking) build on: it walks round by round, matching each - /// round's emitted heats against `completed` by heat id, and only advances to the - /// next round once every heat of the current one 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(); - - let mut round = 1usize; - let mut order = bracket_pairs(&self.field); - let mut eliminated_by_round: Vec> = Vec::new(); - - loop { - // A single survivor (or empty field) ends the bracket — no more heats. - if order.len() <= 1 { - return Replay { - pending: None, - survivors: order, - eliminated_by_round, - }; - } - - let (heats, byes) = self.lay_out_round(round, &order); - - // Are all of this round's heats complete? If any is missing, this round is - // the one we are waiting on — return it as pending. - let results: Option> = heats - .iter() - .map(|h| by_id.get(h.heat.0.as_str()).copied()) - .collect(); - let Some(results) = results else { - return Replay { - pending: Some((round, heats)), - survivors: order, - eliminated_by_round, - }; - }; - - // Round complete: advance each heat's top half, in heat order, then the - // byes; record who this round eliminated (heat losers, in heat order). - let mut next_order = Vec::new(); - let mut eliminated = Vec::new(); - for (heat, result) in heats.iter().zip(results) { - let ranking = result_ranking(result); - let advance = self.advance_count(heat.lineup.len()); - for (i, entry) in ranking.iter().enumerate() { - if i < advance { - next_order.push(entry.competitor.clone()); - } else { - eliminated.push(entry.clone()); - } - } - } - next_order.extend(byes); - eliminated_by_round.push(eliminated); - - order = next_order; - round += 1; - } - } -} - -/// The outcome of replaying the completed history (see [`SingleElim::replay`]). -struct Replay { - /// The round awaiting heats and the heats it needs run, if the bracket is still - /// in progress; `None` once one survivor remains. - pending: Option<(usize, Vec)>, - /// The competitors still in the bracket, in bracket order — one entry once the - /// bracket is complete (the winner). - survivors: Vec, - /// Per round (earliest first), the competitors that round eliminated, each with - /// the in-heat [`RankEntry`] that knocked them out. - eliminated_by_round: Vec>, -} - -impl Generator for SingleElim { - fn next(&mut self, completed: &[CompletedHeat]) -> GeneratorStep { - match self.replay(completed).pending { - Some((_round, heats)) => GeneratorStep::Run(heats), - None => GeneratorStep::Complete, - } - } - - fn ranking(&self, completed: &[CompletedHeat]) -> Vec { - let replay = self.replay(completed); - - // Bands, best first: the survivors still in (the winner once complete, or the - // current field mid-bracket), then each round's eliminated set from the latest - // round (advanced furthest) back to the first (knocked out earliest). - // - // Rows carry a (band, in_band_key) sort key; `rank_by` turns equal-band rows - // into shared positions and the competitor ref is the final tie-break. - let mut rows: Vec<(CompetitorRef, (u32, u32))> = Vec::new(); - let mut band = 0u32; - - // Survivors: still in the bracket. With a single survivor this is the winner - // alone at band 0; mid-bracket it is the live field in bracket order. - for (i, competitor) in replay.survivors.iter().enumerate() { - rows.push((competitor.clone(), (band, i as u32))); - } - band += 1; - - // Eliminated, latest round first (they advanced furthest, so rank higher). - for eliminated in replay.eliminated_by_round.iter().rev() { - for entry in eliminated { - rows.push((entry.competitor.clone(), (band, entry.position))); - } - band += 1; - } - - 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 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"), - } - } - - // --- Round-1 layout ----------------------------------------------------- - - #[test] - fn round_one_pairs_strong_vs_weak_head_to_head() { - let mut g = SingleElim::new(field(&["1", "2", "3", "4"]), 2); - // bracket order 1,4,2,3 → heats (1 v 4), (2 v 3). - assert_eq!( - lineups(&g.next(&[])), - vec![ - ("se-r1-h0".to_string(), vec!["1".into(), "4".into()]), - ("se-r1-h1".to_string(), vec!["2".into(), "3".into()]), - ] - ); - } - - #[test] - fn round_one_odd_field_gives_the_middle_seed_a_bye() { - // 5 seeds: bracket order 1,5,2,4,3 → heats (1 v 5), (2 v 4), and 3 byes. - let mut g = SingleElim::new(field(&["1", "2", "3", "4", "5"]), 2); - assert_eq!( - lineups(&g.next(&[])), - vec![ - ("se-r1-h0".to_string(), vec!["1".into(), "5".into()]), - ("se-r1-h1".to_string(), vec!["2".into(), "4".into()]), - ] - ); - } - - #[test] - fn four_up_heats_chunk_the_bracket_order() { - // 8 seeds, 4-up: bracket order 1,8,2,7,3,6,4,5 → two 4-up heats. - let mut g = SingleElim::new(field(&["1", "2", "3", "4", "5", "6", "7", "8"]), 4); - assert_eq!( - lineups(&g.next(&[])), - vec![ - ( - "se-r1-h0".to_string(), - vec!["1".into(), "8".into(), "2".into(), "7".into()] - ), - ( - "se-r1-h1".to_string(), - vec!["3".into(), "6".into(), "4".into(), "5".into()] - ), - ] - ); - } - - // --- Advancement -------------------------------------------------------- - - #[test] - fn winners_advance_to_the_next_round() { - let mut g = SingleElim::new(field(&["1", "2", "3", "4"]), 2); - // Round 1: seed 1 beats 4, seed 2 beats 3. - let r1 = vec![ - CompletedHeat::new("se-r1-h0", h2h("1", "4")), - CompletedHeat::new("se-r1-h1", h2h("2", "3")), - ]; - // The final lines up the two winners in heat order: 1 v 2. - assert_eq!( - lineups(&g.next(&r1)), - vec![("se-r2-h0".to_string(), vec!["1".into(), "2".into()])] - ); - } - - #[test] - fn bye_competitor_advances_without_a_heat() { - // 3 seeds: round 1 is (1 v 3) plus a bye for seed 2. - let mut g = SingleElim::new(field(&["1", "2", "3"]), 2); - assert_eq!( - lineups(&g.next(&[])), - vec![("se-r1-h0".to_string(), vec!["1".into(), "3".into()])] - ); - // Seed 1 wins its heat; seed 2 had the bye → final is 1 v 2. - let r1 = vec![CompletedHeat::new("se-r1-h0", h2h("1", "3"))]; - assert_eq!( - lineups(&g.next(&r1)), - vec![("se-r2-h0".to_string(), vec!["1".into(), "2".into()])] - ); - } - - #[test] - fn four_up_advances_top_two_of_each_heat() { - let mut g = SingleElim::new(field(&["1", "2", "3", "4", "5", "6", "7", "8"]), 4); - let _ = g.next(&[]); - // Heat h0 (1,8,2,7): order 2,1,8,7 → 2,1 advance. Heat h1 (3,6,4,5): order - // 4,5,3,6 → 4,5 advance. Next round (4-up) is one heat of those four. - let r1 = vec![ - CompletedHeat::new( - "se-r1-h0", - result(&[("2", 1, 6), ("1", 2, 5), ("8", 3, 4), ("7", 4, 3)]), - ), - CompletedHeat::new( - "se-r1-h1", - result(&[("4", 1, 6), ("5", 2, 5), ("3", 3, 4), ("6", 4, 3)]), - ), - ]; - assert_eq!( - lineups(&g.next(&r1)), - vec![( - "se-r2-h0".to_string(), - vec!["2".into(), "1".into(), "4".into(), "5".into()] - )] - ); - } - - // --- Completion --------------------------------------------------------- - - #[test] - fn bracket_completes_when_one_remains() { - let mut g = SingleElim::new(field(&["1", "2", "3", "4"]), 2); - let completed = vec![ - CompletedHeat::new("se-r1-h0", h2h("1", "4")), - CompletedHeat::new("se-r1-h1", h2h("2", "3")), - // Final: seed 2 beats seed 1. - CompletedHeat::new("se-r2-h0", h2h("2", "1")), - ]; - assert_eq!(g.next(&completed), GeneratorStep::Complete); - } - - #[test] - fn single_competitor_field_completes_immediately() { - let mut g = SingleElim::new(field(&["1"]), 2); - assert_eq!(g.next(&[]), GeneratorStep::Complete); - assert_eq!(names(&g.ranking(&[])), vec!["1"]); - } - - // --- Final ranking ------------------------------------------------------ - - #[test] - fn final_ranking_is_winner_runner_up_then_by_round_eliminated() { - let mut g = SingleElim::new(field(&["1", "2", "3", "4"]), 2); - let completed = vec![ - // Round 1: 1 beats 4, 2 beats 3. - CompletedHeat::new("se-r1-h0", h2h("1", "4")), - CompletedHeat::new("se-r1-h1", h2h("2", "3")), - // Final: 2 beats 1. - CompletedHeat::new("se-r2-h0", h2h("2", "1")), - ]; - assert_eq!(g.next(&completed), GeneratorStep::Complete); - - let ranking = g.ranking(&completed); - // Winner 2, runner-up 1 (lost the final), then the round-1 losers (3, 4) - // ordered by competitor ref as the final tie-break. - assert_eq!(names(&ranking), vec!["2", "1", "3", "4"]); - assert_eq!(ranking[0].position, 1); - assert_eq!(ranking[1].position, 2); - assert_eq!(ranking[2].position, 3); - assert_eq!(ranking[3].position, 3); // 3 and 4 share the round-1 elimination band - } - - #[test] - fn provisional_ranking_tracks_state() { - let g = SingleElim::new(field(&["1", "2", "3", "4"]), 2); - // Before any heat: bracket order is the provisional ranking. - assert_eq!(names(&g.ranking(&[])), vec!["1", "4", "2", "3"]); - // After round 1: the two winners lead (still in), then the two losers. - let r1 = vec![ - CompletedHeat::new("se-r1-h0", h2h("1", "4")), - CompletedHeat::new("se-r1-h1", h2h("2", "3")), - ]; - let ranking = g.ranking(&r1); - // Survivors 1, 2 lead (band 0, bracket order); losers 3, 4 share the next band. - assert_eq!(&names(&ranking)[..2], &["1".to_string(), "2".to_string()]); - assert_eq!(ranking[0].position, 1); - assert_eq!(ranking[1].position, 2); - assert_eq!(ranking[2].position, 3); - assert_eq!(ranking[3].position, 3); - } - - // --- Determinism -------------------------------------------------------- - - #[test] - fn next_is_deterministic_for_the_same_history() { - let mut g1 = SingleElim::new(field(&["1", "2", "3", "4"]), 2); - let mut g2 = SingleElim::new(field(&["1", "2", "3", "4"]), 2); - assert_eq!(g1.next(&[]), g2.next(&[])); - } - - #[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 = SingleElim::from_config(&cfg); - let mut g2 = SingleElim::from_config(&cfg); - let s1 = g1.next(&[]); - assert_eq!(s1, g2.next(&[])); - // Drawn order 3,1,4,2 → bracket order 3,2,1,4 → heats (3 v 2), (1 v 4). - assert_eq!( - lineups(&s1), - vec![ - ("se-r1-h0".to_string(), vec!["3".into(), "2".into()]), - ("se-r1-h1".to_string(), vec!["1".into(), "4".into()]), - ] - ); - } - - // --- Registry ----------------------------------------------------------- - - #[test] - fn registry_builds_single_elim() { - let mut registry = FormatRegistry::new(); - SingleElim::register(&mut registry); - assert_eq!(registry.names(), vec!["single_elim"]); - - let cfg = FormatConfig::new(field(&["1", "2", "3", "4"])); - let mut g = registry - .build(SingleElim::NAME, &cfg) - .expect("single_elim is registered"); - assert_eq!( - lineups(&g.next(&[])), - vec![ - ("se-r1-h0".to_string(), vec!["1".into(), "4".into()]), - ("se-r1-h1".to_string(), vec!["2".into(), "3".into()]), - ] - ); - } - - #[test] - fn registry_reads_the_heat_size_param() { - let mut registry = FormatRegistry::new(); - SingleElim::register(&mut registry); - let cfg = FormatConfig::new(field(&["1", "2", "3", "4", "5", "6", "7", "8"])) - .with_param("heat_size", "4"); - let mut g = registry.build(SingleElim::NAME, &cfg).unwrap(); - let step = g.next(&[]); - // 4-up: two heats of four. - assert_eq!(lineups(&step).len(), 2); - assert_eq!(lineups(&step)[0].1.len(), 4); - } - - // --- Larger bracket end-to-end (table) ---------------------------------- - - #[test] - fn full_eight_seed_bracket_runs_to_a_single_winner() { - let mut g = SingleElim::new(field(&["1", "2", "3", "4", "5", "6", "7", "8"]), 2); - // Round 1: bracket 1,8,2,7,3,6,4,5 → heats (1v8),(2v7),(3v6),(4v5). - let r1 = vec![ - CompletedHeat::new("se-r1-h0", h2h("1", "8")), - CompletedHeat::new("se-r1-h1", h2h("2", "7")), - CompletedHeat::new("se-r1-h2", h2h("3", "6")), - CompletedHeat::new("se-r1-h3", h2h("4", "5")), - ]; - // Round 2 (semis): winners 1,2,3,4 → heats (1v2),(3v4). - assert_eq!( - lineups(&g.next(&r1)), - vec![ - ("se-r2-h0".to_string(), vec!["1".into(), "2".into()]), - ("se-r2-h1".to_string(), vec!["3".into(), "4".into()]), - ] - ); - let mut completed = r1; - completed.push(CompletedHeat::new("se-r2-h0", h2h("1", "2"))); - completed.push(CompletedHeat::new("se-r2-h1", h2h("4", "3"))); - // Round 3 (final): 1 v 4. - assert_eq!( - lineups(&g.next(&completed)), - vec![("se-r3-h0".to_string(), vec!["1".into(), "4".into()])] - ); - completed.push(CompletedHeat::new("se-r3-h0", h2h("1", "4"))); - assert_eq!(g.next(&completed), GeneratorStep::Complete); - - // Final ranking: winner 1, runner-up 4, then the semi losers (2,3), then the - // round-1 losers (5,6,7,8), each band sharing a position. - let ranking = g.ranking(&completed); - assert_eq!(ranking[0].competitor, cref("1")); - assert_eq!(ranking[0].position, 1); - assert_eq!(ranking[1].competitor, cref("4")); - assert_eq!(ranking[1].position, 2); - // Semi losers 2 and 3 share position 3. - let semi: Vec<&str> = ranking[2..4] - .iter() - .map(|e| e.competitor.0.as_str()) - .collect(); - assert_eq!(semi, vec!["2", "3"]); - assert_eq!(ranking[2].position, 3); - assert_eq!(ranking[3].position, 3); - // Round-1 losers 5,6,7,8 share position 5. - assert_eq!(ranking[4].position, 5); - assert_eq!(ranking[7].position, 5); - assert_eq!(ranking.len(), 8); - } -} diff --git a/crates/engine/src/timed_qual.rs b/crates/engine/src/timed_qual.rs index 66fd957..a39e698 100644 --- a/crates/engine/src/timed_qual.rs +++ b/crates/engine/src/timed_qual.rs @@ -3,11 +3,16 @@ //! # What qualifying is here //! //! Qualifying "runs rounds and aggregates flights into a ranking" (RE §3): the seeded -//! field flies a fixed number of **rounds**, each round being a *flight* (one heat over -//! the whole field for v0.3 — see the splitting note below), and a pilot's standing is -//! their **best flight** across those rounds — *not* an average, and *not* a sum. This -//! is the standard FPV qualifying rule: your single best run is the one that counts, so -//! one great flight carries you regardless of a scrappy round. +//! field flies a fixed number of **rounds** (rotations), and a pilot's standing is +//! their **best flight** across every heat they flew — *not* an average, and *not* a +//! sum. This is the standard FPV qualifying rule: your single best run is the one that +//! counts, so one great flight carries you regardless of a scrappy round. +//! +//! Each rotation **chunks the seeded field into heats of `heat_size`** (#326): a 64-pilot +//! time trial with `heat_size = 4` flies 16 heats per rotation, not one impossible 64-up +//! heat. A field at or under `heat_size` is a single whole-field heat (the small-field +//! case). The ranking aggregates each pilot's best across *all* their heats, so chunking +//! changes only how heats map to rotations, never the best-flight rule. //! //! # The qualifying metric (config-selected, best-lap by default) //! @@ -30,25 +35,28 @@ //! //! # Determinism (RE §6) //! -//! `next` and `ranking` read no clock and no RNG: `next` is a pure function of the -//! number of completed rounds vs the configured `rounds`, and `ranking` a pure function -//! of the completed history plus the seeded field. The seed order (after any recorded -//! [`SeedingOutcome`]) is the round lineup and the final tie-break, so the same history -//! always yields the same heats and the same ranking — a recorded session replays -//! identically. +//! `next` and `ranking` read no clock and no RNG: `next` is a pure function of which of +//! the rotation's chunk heats are already in the completed history vs the configured +//! `rounds`, and `ranking` a pure function of the completed history plus the seeded +//! field. The seed order (after any recorded [`SeedingOutcome`]) is the chunk order and +//! the final tie-break, and heat ids (`tq-r{rotation}-h{heat}`) are derived purely from +//! the rotation + chunk index, so the same history always yields the same heats and the +//! same ranking — a recorded session replays identically. //! -//! # v0.3 scope: one heat per round +//! # Heat formation: generator vs. static channel-balancing //! -//! For v0.3 a round is a single heat over the whole field. Flight grouping / -//! heat-splitting for fields too large for one heat (multiple heats per round, then -//! aggregate every pilot's best flight across *all* their heats) is a later refinement; -//! the aggregation is already written against "best result across the rounds a pilot -//! flew", so splitting only changes how heats map to rounds, not the ranking rule. +//! This generator's `next` is the **per-heat** formation path: it chunks the field into +//! consecutive heats of `heat_size`. Production time-trial rounds default to +//! [`ChannelMode::Static`](crate::format) channel formation, which the *server* +//! (`round_engine::fill_round_static`) lays out instead — channel-balanced heats off each +//! pilot's fixed channel, capped at the timer's node count. Either way the heats feed the +//! **same** `ranking` here (it aggregates by competitor, independent of heat ids), so the +//! best-flight standing is identical regardless of which formation built the heats. //! //! [`rank_by`]: crate::format::rank_by #![forbid(unsafe_code)] -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use gridfpv_events::CompetitorRef; @@ -92,15 +100,32 @@ impl QualMetric { /// /// Lap-time metrics map straight through (µs, smaller better); most-laps negates the /// count so "more laps" becomes "smaller key" under the same min-is-best rule. - fn round_key(self, placement_metric: Metric, laps: u32) -> Option { + /// + /// `best_lap_micros` is the placement's **condition-independent** best lap: the + /// [`BestLap`](QualMetric::BestLap) qualifier ranks on it regardless of which win + /// condition scored the heat. Before this, a round scored under a non-BestLap + /// condition (e.g. FirstToLaps carrying `Metric::ReachedAt`) yielded `None` for + /// every pilot — the whole field tied at 1 and "ranked" alphabetically, and any + /// `FromRanking` bracket seeded from that order. + fn round_key( + self, + placement_metric: Metric, + laps: u32, + best_lap_micros: Option, + ) -> Option { match (self, placement_metric) { - (QualMetric::BestLap, Metric::BestLapMicros(micros)) => micros, + // Best-lap qualifying: the matched metric when the round was scored under + // BestLap; otherwise the condition-independent `Placement.best_lap_micros` + // (which exists precisely so cross-heat rankings survive the mismatch). + (QualMetric::BestLap, Metric::BestLapMicros(micros)) => micros.or(best_lap_micros), + (QualMetric::BestLap, _) => best_lap_micros, (QualMetric::BestConsecutive, Metric::BestConsecutiveMicros(sum)) => sum, // Most-laps: ignore the placement metric, rank on the counted laps. (QualMetric::MostLaps, _) => Some(-(laps as i64)), - // Metric/condition mismatch (a round scored under a different condition than - // the configured qualifying metric): treat as "no qualifying value", so the - // pilot ranks last for that round rather than corrupting the aggregate. + // Metric/condition mismatch with no condition-independent equivalent (a + // best-consecutive qualifier over a round scored under another condition): + // "no qualifying value" — the pilot ranks in the no-value band rather than + // corrupting the aggregate. _ => None, } } @@ -109,17 +134,23 @@ impl QualMetric { /// A timed-qualifying generator: the seeded field flies `rounds` rounds, ranked by each /// pilot's **best flight** under the configured [`QualMetric`] (RE §3, §7.1). /// -/// Construct with [`TimedQualifying::new`] or, via the registry, [`from_config`] under -/// the name [`NAME`](Self::NAME). `next` emits one heat per round until `rounds` rounds -/// are completed, then [`GeneratorStep::Complete`]; `ranking` aggregates best-flight. +/// Construct with [`TimedQualifying::new`] / [`with_heat_size`](Self::with_heat_size) or, via the +/// registry, [`from_config`] under the name [`NAME`](Self::NAME). `next` emits a rotation's heats +/// (the field chunked into heats of `heat_size`) until `rounds` rotations are completed, then +/// [`GeneratorStep::Complete`]; `ranking` aggregates best-flight across every heat. /// /// [`from_config`]: TimedQualifying::from_config pub struct TimedQualifying { - /// The field flying each round, in seed/draw order (recorded outcome already - /// applied). The lineup of every round and the final ranking tie-break. + /// The field flying each rotation, in seed/draw order (recorded outcome already + /// applied). Chunked into heats of `heat_size`; also the final ranking tie-break. field: Vec, - /// How many rounds the field flies before the format completes. + /// How many rounds (rotations) the field flies before the format completes. **`0` is + /// open-ended**: the generator keeps emitting the next rotation on demand forever (the RD ends + /// it by not requesting more) — "Heats per pilot = 0". rounds: usize, + /// Pilots per heat: each rotation chunks the field into consecutive heats of this size (the + /// last possibly short). Clamped to ≥ 1. A field at or under `heat_size` is one whole-field heat. + heat_size: usize, /// Which round metric the cross-round aggregation ranks by. metric: QualMetric, } @@ -131,25 +162,61 @@ impl TimedQualifying { /// The default number of qualifying rounds when `rounds` is not configured. pub const DEFAULT_ROUNDS: usize = 3; - /// Build over `field` for `rounds` rounds, ranked by `metric`. The field is taken in - /// the given order (apply any [`crate::format::SeedingOutcome`] before calling, as - /// [`from_config`](Self::from_config) does). + /// The default pilots-per-heat when `heat_size` is not configured (a standard 4-up heat). + pub const DEFAULT_HEAT_SIZE: usize = 4; + + /// The most qualifying rotations a round accepts (`rounds = 0` stays the open-ended + /// sentinel). The static fill path materializes the round's full heat plan per fill, so an + /// unchecked param (`rounds=999999999` over the raw API) would allocate unbounded memory. + /// 100 heats per pilot is far beyond any real qualifying day; a request past it clamps. + pub const MAX_ROUNDS: usize = 100; + + /// Build over `field` for `rounds` rotations, ranked by `metric`, chunking each rotation into + /// [`DEFAULT_HEAT_SIZE`](Self::DEFAULT_HEAT_SIZE) heats. The field is taken in the given order + /// (apply any [`crate::format::SeedingOutcome`] before calling, as + /// [`from_config`](Self::from_config) does). For a custom heat size use + /// [`with_heat_size`](Self::with_heat_size). pub fn new(field: Vec, rounds: usize, metric: QualMetric) -> Self { + Self::with_heat_size(field, rounds, Self::DEFAULT_HEAT_SIZE, metric) + } + + /// Build over `field` for `rounds` rotations, chunking each rotation into heats of `heat_size` + /// (clamped to ≥ 1), ranked by `metric`. + pub fn with_heat_size( + field: Vec, + rounds: usize, + heat_size: usize, + metric: QualMetric, + ) -> Self { Self { field, - rounds, + // 0 = open-ended (generate the next heat on demand); a positive count clamps to + // MAX_ROUNDS so the materialized plan is bounded. + rounds: if rounds == 0 { + 0 + } else { + rounds.min(Self::MAX_ROUNDS) + }, + 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)) and the `metric` param - /// (default [`QualMetric::BestLap`]). + /// `rounds` (default [`DEFAULT_ROUNDS`](Self::DEFAULT_ROUNDS)), `heat_size` (default + /// [`DEFAULT_HEAT_SIZE`](Self::DEFAULT_HEAT_SIZE)) and the `metric` param (default + /// [`QualMetric::BestLap`]). + /// + /// `heat_size` is **not** declared in the offered format schema: production time-trial rounds + /// form heats in [`ChannelMode::Static`](crate::format) (the server sizes heats by the timer's + /// node count, not this param), so surfacing a heat-size knob there would mislead. The param is + /// still read here so a `PerHeat` round can carry one, and so the generator chunks sensibly. 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 = QualMetric::parse(config.params.get("metric").map(String::as_str)); - Box::new(Self::new(field, rounds, metric)) + Box::new(Self::with_heat_size(field, rounds, heat_size, metric)) } /// Register this format under [`NAME`](Self::NAME). @@ -157,26 +224,50 @@ impl TimedQualifying { registry.register(Self::NAME, Self::from_config); } - /// Heat id for round `n` (1-based) — `round-1`, `round-2`, … - fn round_id(n: usize) -> String { - format!("round-{n}") + /// Heat id for chunk `heat` (1-based) of rotation `rotation` (1-based) — + /// `tq-r1-h1`, `tq-r1-h2`, `tq-r2-h1`, … Unique + stable across rotations and replays. + fn heat_id(rotation: usize, heat: usize) -> String { + format!("tq-r{rotation}-h{heat}") + } + + /// How many heats one rotation chunks the field into (`ceil(field / heat_size)`, ≥ 1). + fn chunks_per_rotation(&self) -> usize { + self.field.len().div_ceil(self.heat_size).max(1) + } + + /// One rotation's heats: the field split into consecutive chunks of `heat_size`, each a heat + /// id'd `tq-r{rotation}-h{n}` (the last chunk possibly short). + fn rotation_heats(&self, rotation: usize) -> Vec { + self.field + .chunks(self.heat_size) + .enumerate() + .map(|(index, chunk)| HeatPlan::new(Self::heat_id(rotation, index + 1), chunk.to_vec())) + .collect() } } impl Generator for TimedQualifying { fn next(&mut self, completed: &[CompletedHeat]) -> GeneratorStep { - // One heat per round: the number of completed heats *is* the rounds run. Emit the - // next round while any remain, otherwise the format is complete. Pure function of - // (completed.len(), rounds) — no clock, no RNG. - let rounds_run = completed.len(); - if rounds_run < self.rounds { - let next_round = rounds_run + 1; - GeneratorStep::Run(vec![HeatPlan::new( - Self::round_id(next_round), - self.field.clone(), - )]) - } else { + // Emit the lowest rotation (1-based) whose chunk heats are not yet all completed, as the + // field chunked into heats of `heat_size`. The driver schedules one heat per fill (taking + // the first not-yet-scheduled plan) and only finalizes the whole rotation before its heats + // appear in `completed`, so a rotation stays current until every one of its chunks is in. + // Pure function of (which chunk ids are in `completed`, rounds, heat_size) — no clock, no + // RNG; ids derive purely from rotation + chunk index. `rounds == 0` is **open-ended**: + // every rotation is emitted on demand, never Complete (the RD ends it by not asking more). + if self.field.is_empty() { + return GeneratorStep::Complete; + } + let chunks = self.chunks_per_rotation(); + let done: BTreeSet<&str> = completed.iter().map(|c| c.heat.0.as_str()).collect(); + let mut rotation = 1usize; + while (1..=chunks).all(|heat| done.contains(Self::heat_id(rotation, heat).as_str())) { + rotation += 1; + } + if self.rounds != 0 && rotation > self.rounds { GeneratorStep::Complete + } else { + GeneratorStep::Run(self.rotation_heats(rotation)) } } @@ -191,7 +282,18 @@ impl Generator for TimedQualifying { for heat in completed { for place in &heat.result.places { - let key = self.metric.round_key(place.metric, place.laps); + // A **disqualified** competitor has no qualifying value for that heat: a DQ voids + // the lap result that decided their placement metric, so it must not let them + // out-rank clean pilots on a now-defunct lap time (#226 scored DQs into the heat + // result; this stops the metric aggregation from ranking on a DQ'd pilot's value). + // Their *other* (clean) heats still count; DQ'd in every heat → no value → ranked + // last (band 1), the same place a no-show lands. + let key = if place.disqualified { + None + } else { + self.metric + .round_key(place.metric, place.laps, place.best_lap_micros) + }; let slot = best .entry(place.competitor.competitor.clone()) .or_insert(None); @@ -251,6 +353,7 @@ mod tests { position, laps, metric, + ..Default::default() } } @@ -265,6 +368,7 @@ mod tests { placement(name, (i as u32) + 1, 0, Metric::BestLapMicros(*micros)) }) .collect(), + ..Default::default() } } @@ -278,6 +382,7 @@ mod tests { placement(name, (i as u32) + 1, *laps, Metric::LastLapAt(None)) }) .collect(), + ..Default::default() } } @@ -285,27 +390,28 @@ mod tests { #[test] fn emits_round_one_over_the_whole_field_first() { + // A small field (≤ heat_size) is one whole-field heat, id'd tq-r1-h1. let mut g = TimedQualifying::new(field(&["A", "B", "C"]), 3, QualMetric::BestLap); assert_eq!( g.next(&[]), - GeneratorStep::Run(vec![HeatPlan::new("round-1", field(&["A", "B", "C"]))]) + GeneratorStep::Run(vec![HeatPlan::new("tq-r1-h1", field(&["A", "B", "C"]))]) ); } #[test] - fn emits_each_subsequent_round_numbered_from_history() { + fn emits_each_subsequent_rotation_numbered_from_history() { let mut g = TimedQualifying::new(field(&["A", "B"]), 3, QualMetric::BestLap); - // One round completed → emit round 2. - let r1 = CompletedHeat::new("round-1", best_lap_round(&[("A", Some(2_000_000))])); + // Rotation 1's heat completed → emit rotation 2. + let r1 = CompletedHeat::new("tq-r1-h1", best_lap_round(&[("A", Some(2_000_000))])); assert_eq!( g.next(std::slice::from_ref(&r1)), - GeneratorStep::Run(vec![HeatPlan::new("round-2", field(&["A", "B"]))]) + GeneratorStep::Run(vec![HeatPlan::new("tq-r2-h1", field(&["A", "B"]))]) ); - // Two rounds completed → emit round 3 (the last). - let r2 = CompletedHeat::new("round-2", best_lap_round(&[("A", Some(1_900_000))])); + // Two rotations completed → emit rotation 3 (the last). + let r2 = CompletedHeat::new("tq-r2-h1", best_lap_round(&[("A", Some(1_900_000))])); assert_eq!( g.next(&[r1, r2]), - GeneratorStep::Run(vec![HeatPlan::new("round-3", field(&["A", "B"]))]) + GeneratorStep::Run(vec![HeatPlan::new("tq-r3-h1", field(&["A", "B"]))]) ); } @@ -313,12 +419,178 @@ mod tests { fn completes_after_the_configured_rounds() { let mut g = TimedQualifying::new(field(&["A", "B"]), 2, QualMetric::BestLap); let completed = vec![ - CompletedHeat::new("round-1", best_lap_round(&[("A", Some(2_000_000))])), - CompletedHeat::new("round-2", best_lap_round(&[("A", Some(1_900_000))])), + CompletedHeat::new("tq-r1-h1", best_lap_round(&[("A", Some(2_000_000))])), + CompletedHeat::new("tq-r2-h1", best_lap_round(&[("A", Some(1_900_000))])), ]; assert_eq!(g.next(&completed), GeneratorStep::Complete); } + #[test] + fn chunks_a_large_field_into_heats_of_heat_size() { + // 8 pilots, heat_size 4, one rotation → two heats of 4 (consecutive seed-order chunks). + let mut g = TimedQualifying::with_heat_size( + field(&["A", "B", "C", "D", "E", "F", "G", "H"]), + 1, + 4, + QualMetric::BestLap, + ); + assert_eq!( + g.next(&[]), + GeneratorStep::Run(vec![ + HeatPlan::new("tq-r1-h1", field(&["A", "B", "C", "D"])), + HeatPlan::new("tq-r1-h2", field(&["E", "F", "G", "H"])), + ]) + ); + } + + #[test] + fn chunked_rotation_advances_only_once_all_its_heats_are_in() { + // 8 pilots / heat_size 4 → 2 heats per rotation. The rotation stays current until BOTH its + // heats are completed; only then does rotation 2 appear. Heat ids are unique across both. + let mut g = TimedQualifying::with_heat_size( + field(&["A", "B", "C", "D", "E", "F", "G", "H"]), + 2, + 4, + QualMetric::BestLap, + ); + // Only the first chunk of rotation 1 is in → still rotation 1 (so the driver can pick h2). + let r1h1 = CompletedHeat::new("tq-r1-h1", best_lap_round(&[("A", Some(2_000_000))])); + assert_eq!( + g.next(std::slice::from_ref(&r1h1)), + GeneratorStep::Run(vec![ + HeatPlan::new("tq-r1-h1", field(&["A", "B", "C", "D"])), + HeatPlan::new("tq-r1-h2", field(&["E", "F", "G", "H"])), + ]) + ); + // Both chunks of rotation 1 in → rotation 2 (a fresh pair of unique heat ids). + let r1h2 = CompletedHeat::new("tq-r1-h2", best_lap_round(&[("E", Some(2_000_000))])); + assert_eq!( + g.next(&[r1h1, r1h2]), + GeneratorStep::Run(vec![ + HeatPlan::new("tq-r2-h1", field(&["A", "B", "C", "D"])), + HeatPlan::new("tq-r2-h2", field(&["E", "F", "G", "H"])), + ]) + ); + } + + #[test] + fn chunked_ranking_aggregates_each_pilots_best_across_their_heats() { + // 8 pilots, heat_size 4, two rotations. Each pilot flies their chunk twice; the ranking + // takes their best single lap across both heats — chunking must not break the aggregate. + let g = TimedQualifying::with_heat_size( + field(&["A", "B", "C", "D", "E", "F", "G", "H"]), + 2, + 4, + QualMetric::BestLap, + ); + let completed = vec![ + // Rotation 1. + CompletedHeat::new( + "tq-r1-h1", + best_lap_round(&[ + ("A", Some(2_000_000)), + ("B", Some(2_100_000)), + ("C", Some(2_200_000)), + ("D", Some(2_300_000)), + ]), + ), + CompletedHeat::new( + "tq-r1-h2", + best_lap_round(&[ + ("E", Some(2_400_000)), + ("F", Some(2_500_000)), + ("G", Some(2_600_000)), + ("H", Some(2_700_000)), + ]), + ), + // Rotation 2: A improves to the fastest lap overall; H improves but stays slowest. + CompletedHeat::new( + "tq-r2-h1", + best_lap_round(&[ + ("A", Some(1_500_000)), + ("B", Some(2_050_000)), + ("C", Some(2_150_000)), + ("D", Some(2_250_000)), + ]), + ), + CompletedHeat::new( + "tq-r2-h2", + best_lap_round(&[ + ("E", Some(2_350_000)), + ("F", Some(2_450_000)), + ("G", Some(2_550_000)), + ("H", Some(2_650_000)), + ]), + ), + ]; + // Best single lap per pilot across both heats → A 1.5, B 2.05, C 2.15, D 2.25, E 2.35, + // F 2.45, G 2.55, H 2.65: a strict A..H order. + let ranking = g.ranking(&completed); + assert_eq!( + names(&ranking), + vec!["A", "B", "C", "D", "E", "F", "G", "H"] + ); + assert_eq!(ranking[0].position, 1); + assert_eq!(ranking[7].position, 8); + } + + #[test] + fn zero_rounds_is_open_ended_and_never_completes() { + // "Heats per pilot = 0": the generator keeps emitting the next rotation forever. + let mut g = TimedQualifying::new(field(&["A", "B"]), 0, QualMetric::BestLap); + let mut completed: Vec = Vec::new(); + for r in 1..=20 { + assert_eq!( + g.next(&completed), + GeneratorStep::Run(vec![HeatPlan::new( + format!("tq-r{r}-h1"), + field(&["A", "B"]) + )]), + "open-ended (rounds=0) must keep emitting rotation {r}, never Complete" + ); + completed.push(CompletedHeat::new( + format!("tq-r{r}-h1"), + best_lap_round(&[("A", Some(2_000_000))]), + )); + } + } + + #[test] + fn rounds_clamp_to_max_rounds_but_zero_stays_open_ended() { + // An unchecked `rounds=999999` (raw API) clamps to MAX_ROUNDS: the round plans at most + // MAX_ROUNDS rotations, then completes — no unbounded materialized plan. + let mut g = + TimedQualifying::with_heat_size(field(&["A", "B"]), 999_999, 4, QualMetric::BestLap); + assert_eq!(g.rounds, TimedQualifying::MAX_ROUNDS); + let completed: Vec = (1..=TimedQualifying::MAX_ROUNDS) + .map(|r| { + CompletedHeat::new( + format!("tq-r{r}-h1"), + best_lap_round(&[("A", Some(2_000_000))]), + ) + }) + .collect(); + assert_eq!( + g.next(&completed), + GeneratorStep::Complete, + "the clamped round completes after MAX_ROUNDS rotations" + ); + + // `rounds = 0` stays the open-ended sentinel — NOT clamped into a finite plan: it + // keeps emitting the next rotation even past the cap. + let mut open = + TimedQualifying::with_heat_size(field(&["A", "B"]), 0, 4, QualMetric::BestLap); + assert_eq!(open.rounds, 0); + assert_eq!( + open.next(&completed), + GeneratorStep::Run(vec![HeatPlan::new( + format!("tq-r{}-h1", TimedQualifying::MAX_ROUNDS + 1), + field(&["A", "B"]) + )]), + "rounds=0 keeps emitting past the clamp" + ); + } + #[test] fn next_is_deterministic_for_the_same_history() { let mut g1 = TimedQualifying::new(field(&["A", "B"]), 3, QualMetric::BestLap); @@ -349,6 +621,42 @@ mod tests { assert_eq!(ranking[1].position, 2); } + #[test] + fn ranking_best_lap_survives_a_win_condition_mismatch_via_placement_best_lap() { + // A best-lap qualifier over a round scored under a DIFFERENT win condition (e.g. + // FirstToLaps → Metric::ReachedAt): the metric field carries no best lap, but the + // placement's condition-independent `best_lap_micros` does. Before the fallback the + // whole field yielded `None` → everyone tied at 1, ordered alphabetically — and any + // FromRanking bracket seeded from that order. + let g = TimedQualifying::new(field(&["A", "B", "C"]), 1, QualMetric::BestLap); + let places = [("B", 1_400_000), ("C", 1_600_000), ("A", 1_900_000)] + .iter() + .enumerate() + .map(|(i, (name, best))| Placement { + best_lap_micros: Some(*best), + ..placement( + name, + (i as u32) + 1, + 3, + Metric::ReachedAt(Some(gridfpv_events::SourceTime { micros: 9_000_000 })), + ) + }) + .collect(); + let ranking = g.ranking(&[CompletedHeat::new( + "round-1", + HeatResult { + places, + ..Default::default() + }, + )]); + // Ranked by best lap — B, C, A — with distinct positions, not an alphabetical tie. + assert_eq!(names(&ranking), vec!["B", "C", "A"]); + assert_eq!( + ranking.iter().map(|r| r.position).collect::>(), + vec![1, 2, 3] + ); + } + #[test] fn ranking_no_lap_pilot_ranks_last() { // C never set a qualifying lap in any round → ranks behind everyone who did. @@ -416,6 +724,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). @@ -465,7 +774,7 @@ mod tests { GeneratorStep::Complete => panic!("completed early at round {i}"), }; assert_eq!(plans.len(), 1); - assert_eq!(plans[0].heat.0, format!("round-{}", i + 1)); + assert_eq!(plans[0].heat.0, format!("tq-r{}-h1", i + 1)); let rows: Vec<(&str, Option)> = round.iter().map(|(n, m)| (*n, Some(*m))).collect(); history.push(CompletedHeat::new( @@ -497,15 +806,15 @@ mod tests { let mut g = registry .build(TimedQualifying::NAME, &cfg) .expect("timed_qual is registered"); - // Round 1 over the whole field. + // Rotation 1 over the whole (small) field. assert_eq!( g.next(&[]), - GeneratorStep::Run(vec![HeatPlan::new("round-1", field(&["A", "B", "C"]))]) + GeneratorStep::Run(vec![HeatPlan::new("tq-r1-h1", field(&["A", "B", "C"]))]) ); - // And it completes after exactly the configured 2 rounds. + // And it completes after exactly the configured 2 rotations. let completed = vec![ - CompletedHeat::new("round-1", best_lap_round(&[("A", Some(2_000_000))])), - CompletedHeat::new("round-2", best_lap_round(&[("A", Some(1_900_000))])), + CompletedHeat::new("tq-r1-h1", best_lap_round(&[("A", Some(2_000_000))])), + CompletedHeat::new("tq-r2-h1", best_lap_round(&[("A", Some(1_900_000))])), ]; assert_eq!(g.next(&completed), GeneratorStep::Complete); } @@ -516,8 +825,8 @@ mod tests { let cfg = FormatConfig::new(field(&["A", "B"])); let mut g = TimedQualifying::from_config(&cfg); let two_done = vec![ - CompletedHeat::new("round-1", best_lap_round(&[("A", Some(2_000_000))])), - CompletedHeat::new("round-2", best_lap_round(&[("A", Some(1_900_000))])), + CompletedHeat::new("tq-r1-h1", best_lap_round(&[("A", Some(2_000_000))])), + CompletedHeat::new("tq-r2-h1", best_lap_round(&[("A", Some(1_900_000))])), ]; // Still running after 2 (default is 3). assert!(matches!(g.next(&two_done), GeneratorStep::Run(_))); @@ -533,7 +842,72 @@ mod tests { let mut g = TimedQualifying::from_config(&cfg); assert_eq!( g.next(&[]), - GeneratorStep::Run(vec![HeatPlan::new("round-1", field(&["C", "A", "D", "B"]))]) + GeneratorStep::Run(vec![HeatPlan::new( + "tq-r1-h1", + field(&["C", "A", "D", "B"]) + )]) + ); + } + + #[test] + fn config_reads_the_heat_size_param_and_chunks() { + // heat_size=2 over a field of 5 → 3 heats per rotation (2, 2, 1) — the param is honoured. + let cfg = FormatConfig::new(field(&["A", "B", "C", "D", "E"])) + .with_param("rounds", "1") + .with_param("heat_size", "2"); + let mut g = TimedQualifying::from_config(&cfg); + assert_eq!( + g.next(&[]), + GeneratorStep::Run(vec![ + HeatPlan::new("tq-r1-h1", field(&["A", "B"])), + HeatPlan::new("tq-r1-h2", field(&["C", "D"])), + HeatPlan::new("tq-r1-h3", field(&["E"])), + ]) ); } + + // --- ranking(): a disqualified pilot is excluded from the metric aggregate ------------------ + + #[test] + fn ranking_disqualified_pilot_does_not_outrank_clean_pilots() { + // B sets the fastest lap on track but is DISQUALIFIED in that heat: the DQ voids their + // qualifying value, so they must NOT out-rank A on the lap time. With no other heat, B has + // no value at all → ranked last, behind clean A. + let g = TimedQualifying::new(field(&["A", "B"]), 1, QualMetric::BestLap); + let mut round = best_lap_round(&[("A", Some(2_000_000)), ("B", Some(1_500_000))]); + // Flag B's placement disqualified (as the adjudicated scorer does for a DQ penalty). + for place in &mut round.places { + if place.competitor.competitor == cref("B") { + place.disqualified = true; + } + } + let ranking = g.ranking(&[CompletedHeat::new("tq-r1-h1", round)]); + // A (clean, 2.0s) ahead of B (DQ'd, no qualifying value) despite B's faster on-track lap. + assert_eq!(names(&ranking), vec!["A", "B"]); + assert_eq!(ranking[0].competitor, cref("A")); + assert_eq!(ranking[0].position, 1); + assert_eq!(ranking[1].position, 2); + } + + #[test] + fn ranking_dq_in_one_heat_still_counts_a_clean_heat() { + // A pilot DQ'd in one rotation but clean in another keeps the clean heat's value. + // B is DQ'd in rotation 1 (where they were fastest) but flies clean in rotation 2 at 1.9s, + // which still beats A's best (2.0s) — the clean heat is not discarded with the DQ'd one. + let g = TimedQualifying::new(field(&["A", "B"]), 2, QualMetric::BestLap); + let mut r1 = best_lap_round(&[("A", Some(2_000_000)), ("B", Some(1_400_000))]); + for place in &mut r1.places { + if place.competitor.competitor == cref("B") { + place.disqualified = true; + } + } + let r2 = best_lap_round(&[("A", Some(2_100_000)), ("B", Some(1_900_000))]); + let ranking = g.ranking(&[ + CompletedHeat::new("tq-r1-h1", r1), + CompletedHeat::new("tq-r2-h1", r2), + ]); + // B's clean rotation-2 lap (1.9s) counts and beats A's best (2.0s); the DQ'd 1.4s is voided. + assert_eq!(names(&ranking), vec!["B", "A"]); + assert_eq!(ranking[0].competitor, cref("B")); + } } 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/common/mod.rs b/crates/engine/tests/common/mod.rs index faf4804..66234ef 100644 --- a/crates/engine/tests/common/mod.rs +++ b/crates/engine/tests/common/mod.rs @@ -97,13 +97,13 @@ fn wait_until( /// `CompetitorRef("node-{i}")` per scenario node index, in scenario order), /// 2. the forward-path [`Event::HeatStateChanged`] transitions the FSM records — /// `Staged`, `Armed`, then (after RH actually starts) `Running`, then `Finished` -/// and `Scored`, +/// and `Finalized`, /// 3. interleaved between the `Running` and `Finished` transitions, the /// [`Event::Pass`]es the timer produced while the heat was live (other adapter /// events such as lifecycle/`CompetitorSeen` are dropped — only `Pass`es are kept). /// /// Folding the result with [`gridfpv_engine::heat::heat_state`] yields -/// [`HeatState::Scored`]; the passes all fall in the live window, so cross-checking +/// [`HeatState::Final`]; the passes all fall in the live window, so cross-checking /// each pass's surrounding state with [`gridfpv_engine::heat::consumes_pass`] holds. /// /// Timing is tolerant: it waits for RH to reach `RACING` and for at least one pass, @@ -124,6 +124,10 @@ pub fn run_mock_heat(port: u16, heat: &str, scenario: &[(usize, String)]) -> Vec let mut log: Vec = vec![Event::HeatScheduled { heat: heat.clone(), lineup, + class: None, + round: None, + frequencies: vec![], + label: None, }]; // The heat's current FSM state, validated by `apply` and advanced by `next_state`. let mut state = HeatState::Scheduled; @@ -136,9 +140,9 @@ pub fn run_mock_heat(port: u16, heat: &str, scenario: &[(usize, String)]) -> Vec std::thread::sleep(Duration::from_secs(2)); let _ = conn.events(); // drop the reset's snapshot churn - // Drive the heat loop forward: Scheduled -> Staged -> Armed. + // Drive the heat loop forward: Scheduled -> Staged -> Armed (Start arms the heat). drive(&mut log, &heat, &mut state, HeatCommand::Stage); - drive(&mut log, &heat, &mut state, HeatCommand::Arm); + drive(&mut log, &heat, &mut state, HeatCommand::Start); // Actually start the race on RH (it stages + auto-starts), then record Running. conn.stage_race().expect("stage_race"); @@ -150,7 +154,9 @@ pub fn run_mock_heat(port: u16, heat: &str, scenario: &[(usize, String)]) -> Vec }), "RotorHazard never reached RACING" ); - drive(&mut log, &heat, &mut state, HeatCommand::Start); + // The override stands in for the runtime auto-start here (this harness drives the FSM by + // hand rather than running the Director clock): force Armed -> Running. + drive(&mut log, &heat, &mut state, HeatCommand::SkipCountdown); debug_assert_eq!(state, HeatState::Running); // While Running, poll the timer crossings; keep at least one pass. @@ -162,7 +168,7 @@ pub fn run_mock_heat(port: u16, heat: &str, scenario: &[(usize, String)]) -> Vec conn.stop_race().ok(); std::thread::sleep(Duration::from_millis(800)); live.extend(conn.events()); - conn.disconnect().ok(); + conn.disconnect(); assert!( got_pass, @@ -174,10 +180,11 @@ pub fn run_mock_heat(port: u16, heat: &str, scenario: &[(usize, String)]) -> Vec // are adapter bookkeeping, not part of the heat's canonical race-engine log. log.extend(live.into_iter().filter(|e| matches!(e, Event::Pass(_)))); - // Close the heat loop: Finished -> Scored. - drive(&mut log, &heat, &mut state, HeatCommand::Finish); - drive(&mut log, &heat, &mut state, HeatCommand::Score); - debug_assert_eq!(state, HeatState::Scored); + // Close the heat loop: ForceEnd (Running -> Unofficial) then Finalize. ForceEnd stands in for + // the runtime auto-complete here (the harness has already stopped the RH race). + drive(&mut log, &heat, &mut state, HeatCommand::ForceEnd); + drive(&mut log, &heat, &mut state, HeatCommand::Finalize); + debug_assert_eq!(state, HeatState::Final); log } diff --git a/crates/engine/tests/format_live.rs b/crates/engine/tests/format_live.rs index 585009c..90b566b 100644 --- a/crates/engine/tests/format_live.rs +++ b/crates/engine/tests/format_live.rs @@ -75,6 +75,7 @@ fn scenario() -> Vec<(usize, String)> { ticks_per_lap: 2, peak_rssi: 180, baseline_rssi: 70, + seed: 0, }), ), (1usize, plan_csv(&scenarios::dnf(2, 6))), diff --git a/crates/engine/tests/full_event.rs b/crates/engine/tests/full_event.rs deleted file mode 100644 index 1d0581c..0000000 --- a/crates/engine/tests/full_event.rs +++ /dev/null @@ -1,307 +0,0 @@ -//! Full-event capstone — deterministic fixture replay (#37, the v0.3 milestone). -//! -//! This is the core (non-live) proof that a **whole event runs end to end**: a -//! qualifying phase produces a ranking, that ranking seeds a single-elimination -//! bracket, and the bracket runs to a single winner — with correct results, the -//! marshaling folded in, and **byte-identical on replay** (testing-strategy.html §8). -//! -//! Everything is hand-authored canonical-log heats: each heat is a `Vec` of -//! lap-gate [`Pass`]es (plus, in one heat, a marshaling adjudication). The heats are -//! scored with [`gridfpv_engine::event::score_marshaled`] — the same scorer the -//! un-marshaled path uses, but folding [`Event::DetectionVoided`] / `LapInserted` / -//! `LapAdjusted` into a corrected view first — and the scored results drive the -//! qualifying → bracket flow via [`gridfpv_engine::event::run_event`]. -//! -//! # What the marshaling case proves -//! -//! The qualifying field is A, B, C, D, scored by **best single lap** (smaller is -//! better). In round 1, pilot **D logs a phantom blazing-fast lap** that, taken at face -//! value, makes D the top qualifier. A marshal rules that crossing a false detection -//! and appends a [`Event::DetectionVoided`] for it. With the void folded in, D's real -//! best lap is ordinary and D drops to the bottom of qualifying — which **reseeds the -//! bracket and changes who wins the event**. The test asserts both the un-marshaled and -//! marshaled outcomes so the adjudication's effect is explicit (it is not a no-op). -#![forbid(unsafe_code)] - -use gridfpv_engine::event::{EventOutcome, RunHeat, run_event, score_marshaled}; -use gridfpv_engine::format::{HeatPlan, RankEntry}; -use gridfpv_engine::scoring::{HeatResult, WinCondition}; -use gridfpv_engine::single_elim::SingleElim; -use gridfpv_engine::timed_qual::{QualMetric, TimedQualifying}; -use gridfpv_events::{AdapterId, CompetitorRef, Event, GateIndex, LogRef, Pass, SourceTime}; - -const ADAPTER: &str = "vd"; - -fn cref(name: &str) -> CompetitorRef { - CompetitorRef(name.into()) -} - -fn field(names: &[&str]) -> Vec { - names.iter().map(|n| cref(n)).collect() -} - -/// A lap-gate pass for `competitor` at `at` µs. -fn pass(competitor: &str, at: i64) -> Event { - Event::Pass(Pass { - adapter: AdapterId(ADAPTER.into()), - competitor: cref(competitor), - at: SourceTime::from_micros(at), - sequence: None, - gate: GateIndex::LAP, - signal: None, - }) -} - -fn names(entries: &[RankEntry]) -> Vec { - entries.iter().map(|e| e.competitor.0.clone()).collect() -} - -/// Race start for the timed/qualifying clocks (RH-style: passes are µs since start). -fn race_start() -> SourceTime { - SourceTime::from_micros(0) -} - -// --- The hand-authored event fixture --------------------------------------- - -/// Lap-gate timestamps (µs) for each pilot, so a pilot's best single lap is a clean, -/// reviewable number. Gaps are the per-lap durations. -/// -/// Round-1 best laps (the gap between consecutive passes): -/// - A: passes 0, 1.8, 3.5 → laps 1.8s, 1.7s → best **1.7s** -/// - B: passes 0, 2.0, 3.6 → laps 2.0s, 1.6s → best **1.6s** -/// - C: passes 0, 1.9, 4.0 → laps 1.9s, 2.1s → best **1.9s** -/// - D: passes 0, 0.5, 2.3 → laps **0.5s**, 1.8s → best **0.5s** (the phantom!) -/// -/// D's 0.5s lap is impossibly fast — a phantom double-detection. Taken raw it makes D -/// the top qualifier; voided, D's best becomes 1.8s and D seeds last. -fn round_1_raw() -> Vec { - vec![ - // A — offsets 0,1,2 - pass("A", 0), - pass("A", 1_800_000), - pass("A", 3_500_000), - // B — offsets 3,4,5 - pass("B", 0), - pass("B", 2_000_000), - pass("B", 3_600_000), - // C — offsets 6,7,8 - pass("C", 0), - pass("C", 1_900_000), - pass("C", 4_000_000), - // D — offsets 9,10,11; offset 10 is the phantom 0.5s crossing - pass("D", 0), - pass("D", 500_000), - pass("D", 2_300_000), - ] -} - -/// The qualifying round-1 log **with the marshal's ruling**: void D's phantom crossing -/// (offset 10). Folded in, D's best lap reverts to its real 1.8s pace. -fn round_1_marshaled() -> Vec { - let mut log = round_1_raw(); - log.push(Event::DetectionVoided { target: LogRef(10) }); - log -} - -/// Bracket heat logs, keyed by the head-to-head winner: the winner banks more, faster -/// laps than the loser. Scored by most-laps-in-window, the busier pilot wins. -fn bracket_h2h(winner: &str, loser: &str) -> Vec { - vec![ - // Winner: 4 crossings ⇒ 3 laps, all inside the window. - pass(winner, 0), - pass(winner, 1_500_000), - pass(winner, 3_000_000), - pass(winner, 4_500_000), - // Loser: 3 crossings ⇒ 2 laps. - pass(loser, 0), - pass(loser, 1_800_000), - pass(loser, 3_700_000), - ] -} - -/// The per-heat fixture log lookup. Qualifying round-1 carries the marshaling ruling -/// when `marshaled` is set; bracket heats are derived from the heat id's seed pairing. -/// -/// The bracket heat ids are `single_elim`'s `se-r{round}-h{index}`; for each we know -/// which two seeds it pairs from the seeding, so we author the busier pilot as the -/// higher seed (smaller qualifying position) and let it win. -struct Fixture { - marshaled: bool, -} - -impl Fixture { - /// Score the qualifying round-1 heat (best-lap) or a bracket heat (most-laps), - /// folding marshaling adjudications via [`score_marshaled`]. - fn score_heat(&self, plan: &HeatPlan) -> HeatResult { - if plan.heat.0 == "round-1" { - let log = if self.marshaled { - round_1_marshaled() - } else { - round_1_raw() - }; - score_marshaled(&log, WinCondition::BestLap, race_start()) - } else { - // A bracket heat: the higher-seeded (alphabetically/numerically lower - // qualifying rank) of the two lineup competitors is the intended winner. - // We pick the winner as the lineup's first entry (single_elim lays the - // stronger seed first in each pair via bracket_pairs) so the bracket - // resolves deterministically toward the top seed. - let winner = plan.lineup.first().expect("non-empty lineup"); - let loser = plan - .lineup - .get(1) - .cloned() - .unwrap_or_else(|| winner.clone()); - let log = bracket_h2h(&winner.0, &loser.0); - score_marshaled( - &log, - WinCondition::Timed { - window_micros: 60_000_000, - }, - race_start(), - ) - } - } -} - -impl RunHeat for Fixture { - fn run(&mut self, plan: &HeatPlan) -> HeatResult { - self.score_heat(plan) - } -} - -/// Run the whole fixtured event with or without the marshaling ruling folded in. -fn run_fixture_event(marshaled: bool) -> EventOutcome { - let mut qual = TimedQualifying::new(field(&["A", "B", "C", "D"]), 1, QualMetric::BestLap); - let mut fixture = Fixture { marshaled }; - run_event( - &mut qual, - |seeds| Box::new(SingleElim::new(seeds, 2)), - 4, - &mut fixture, - 64, - ) -} - -// --- Tests ------------------------------------------------------------------ - -#[test] -fn full_event_runs_qualifying_through_bracket_to_a_single_winner() { - // The headline capstone path: with the marshal's ruling folded in, the event runs - // qualifying → bracket → one winner with the EXACT expected results. - let outcome = run_fixture_event(true); - - // Qualifying by best lap, D's phantom voided: - // B 1.6s, A 1.7s, C 1.9s, D 1.8s → B, A, C, D. - assert_eq!( - names(&outcome.qualifying), - vec!["B", "A", "C", "D"], - "qualifying ranking by best lap with D's phantom voided" - ); - // The bracket is seeded by that ranking. - assert_eq!(outcome.bracket_seeds, field(&["B", "A", "C", "D"])); - - // bracket_pairs([B,A,C,D]) = [B,D,A,C] → heats (B v D), (A v C); the higher seed - // (lineup-first) wins each, so the final is B v A and B takes the event. - assert_eq!( - outcome.winner(), - Some(&cref("B")), - "the top qualifier wins the bracket" - ); - assert_eq!(outcome.bracket[0].position, 1); - assert_eq!( - outcome.bracket.iter().filter(|e| e.position == 1).count(), - 1, - "exactly one event winner" - ); - // Every seed appears in the final standings. - assert_eq!(outcome.bracket.len(), 4); -} - -#[test] -fn marshaling_adjudication_changes_the_full_event_result() { - // The load-bearing marshaling proof: voiding D's phantom lap reshapes qualifying, - // which reseeds the bracket, which changes the event winner. - let raw = run_fixture_event(false); - let marshaled = run_fixture_event(true); - - // RAW: D's 0.5s phantom is the fastest lap, so D tops qualifying: - // D 0.5s, B 1.6s, A 1.7s, C 1.9s → D, B, A, C. - assert_eq!( - names(&raw.qualifying), - vec!["D", "B", "A", "C"], - "raw qualifying lets D's phantom lap seed it first" - ); - // MARSHALED: D's phantom voided → D's best is its real 1.8s, so D seeds last. - assert_eq!( - names(&marshaled.qualifying), - vec!["B", "A", "C", "D"], - "the void demotes D to last in qualifying" - ); - - // The reseed changes the bracket winner: raw seeds [D,B,A,C] → pairs [D,C,B,A] → - // heats (D v C),(B v A) → final D v B → D wins. Marshaled → B wins. - assert_eq!( - raw.winner(), - Some(&cref("D")), - "raw: the phantom carries D to the title" - ); - assert_eq!( - marshaled.winner(), - Some(&cref("B")), - "marshaled: B wins instead" - ); - assert_ne!( - raw.winner(), - marshaled.winner(), - "the marshaling adjudication changed the event winner" - ); -} - -#[test] -fn full_event_replays_byte_identically() { - // Determinism backbone (TS §8): run the entire fixtured event twice and assert the - // outcomes are equal AND serialize byte-for-byte identically. Nothing in the flow - // reads a clock or an RNG, so replay is exact. - let first = run_fixture_event(true); - let second = run_fixture_event(true); - - assert_eq!( - first, second, - "two runs of the full event are structurally equal" - ); - - // Byte-identical: serialize the rankings and the completed heats and compare bytes. - let bytes = |o: &EventOutcome| -> (String, String, String, String) { - ( - serde_json_names(&o.qualifying), - serde_json_names(&o.bracket), - heat_ids(&o.qualifying_heats), - heat_ids(&o.bracket_heats), - ) - }; - assert_eq!( - bytes(&first), - bytes(&second), - "the full event replays byte-identically" - ); -} - -/// A stable string rendering of a ranking (competitor + position), used as the -/// byte-identity surface — `RankEntry` is not `Serialize`, so we render it ourselves. -fn serde_json_names(ranking: &[RankEntry]) -> String { - ranking - .iter() - .map(|e| format!("{}:{}", e.competitor.0, e.position)) - .collect::>() - .join(",") -} - -/// The completed heat ids joined, as a second byte-identity surface. -fn heat_ids(heats: &[gridfpv_engine::format::CompletedHeat]) -> String { - heats - .iter() - .map(|h| h.heat.0.clone()) - .collect::>() - .join(",") -} diff --git a/crates/engine/tests/full_event_live.rs b/crates/engine/tests/full_event_live.rs deleted file mode 100644 index 9f3e32f..0000000 --- a/crates/engine/tests/full_event_live.rs +++ /dev/null @@ -1,243 +0,0 @@ -//! Full-event capstone — mock-RH end-to-end (#37, the v0.3 milestone). -//! -//! The live sibling of `full_event`: it drives a **whole event over real dockerized -//! RotorHazard**. A qualifying phase runs its rounds as real mock-RH heats, the scored -//! results aggregate into a qualifying ranking, that ranking seeds a single-elimination -//! bracket, the bracket runs its heats as real mock-RH heats too, and a single winner -//! emerges. This is the §5.1 mock-RH e2e from `docs/testing-strategy.html` stretched -//! across an entire event rather than a single heat. -//! -//! As with the other engine live tests, the mock reads its CSV continuously (lap timing -//! is not controllable), so this is **structural/tolerant**: every heat gives the -//! intended winner a continuously-lapping `node_csv` stream and the other seats a `dnf` -//! stream, and we rely only on "the busy node out-laps the DNF nodes", never on exact -//! µs. The qualifying metric is most-laps and the bracket is scored most-laps-in-window, -//! both of which the busy stream dominates. -//! -//! 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 (needs Docker). DISTINCT port 5040. Run: -//! -//! ```sh -//! cargo test -p gridfpv-engine --features live --test full_event_live -- --ignored --nocapture -//! ``` -#![cfg(feature = "live")] - -mod common; - -use common::run_mock_heat; - -use gridfpv_engine::event::{run_event, score_marshaled}; -use gridfpv_engine::format::HeatPlan; -use gridfpv_engine::scoring::{HeatResult, Metric, Placement, WinCondition}; -use gridfpv_engine::single_elim::SingleElim; -use gridfpv_engine::timed_qual::{QualMetric, TimedQualifying}; -use gridfpv_events::{AdapterId, CompetitorRef, SourceTime}; -use gridfpv_projection::CompetitorKey; -use gridfpv_testkit::{NodeCsv, node_csv, plan_csv, scenarios}; - -/// DISTINCT port for the full-event e2e (heat e2e 5032, scoring 5033, single-elim 5037). -const PORT: u16 = 5040; - -/// A continuously-lapping stream for a 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 other seats: a couple of early laps, then flat. -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 event 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 real mock-RH heat for `plan` with `winner` seated busy on node 0 and every -/// other lineup entry on a DNF node, score it most-laps-in-window, and translate the -/// node-seat placements back onto the event's own competitor refs. -/// -/// This is the same seat-mapping a real scheduler (#36) performs: a plan's lineup maps -/// onto physical nodes, the heat is scored in node-seat space, and the result is then -/// expressed back in the format's competitor namespace. Scoring goes through -/// [`score_marshaled`] so the full-event path is identical to the core fixture replay -/// (no live heat carries adjudications, so it equals plain scoring here). -fn run_real_heat(plan: &HeatPlan, winner: &CompetitorRef) -> HeatResult { - // Seat the intended winner first (node 0, busy); everyone else is a DNF node. - 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_marshaled( - &log, - WinCondition::Timed { - window_micros: 10 * 60 * 1_000_000, - }, - race_start, - ); - - // Translate node-seat placements back onto the event's competitor refs by lineup - // position (`node-{i}` → `ordered[i]`). A node that produced no live-window passes - // is absent from `scored`; such competitors are appended behind the finishers so - // the result stays a total order. - 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(|| AdapterId("rotorhazard".into())), - competitor: ordered[node].clone(), - }, - position: next_pos, - laps: 0, - metric: Metric::LastLapAt(None), - }); - next_pos += 1; - } - } - - HeatResult { places } -} - -/// The intended winner of a heat: the highest seed present in the lineup. Seeds here are -/// the numeric refs "1".."4" from the field, so the smallest numeric ref is the top -/// seed. In qualifying every pilot flies together, so the top seed wins the round (and -/// thus qualifies first); in the bracket the higher seed wins each match. -fn intended_winner(plan: &HeatPlan) -> CompetitorRef { - plan.lineup - .iter() - .min_by_key(|c| c.0.parse::().unwrap_or(u32::MAX)) - .cloned() - .expect("non-empty lineup") -} - -#[test] -#[ignore = "requires Docker (spins up dockerized RotorHazard and drives a full event)"] -fn full_event_runs_to_a_single_winner_over_real_heats() { - // A four-pilot field "1".."4". Qualifying is a single most-laps round; the top seed - // gets the busy stream and qualifies first. The bracket then seeds off that ranking - // and the top seed wins every heat it flies, so it emerges as the event winner. - let field: Vec = ["1", "2", "3", "4"] - .iter() - .map(|n| CompetitorRef(n.to_string())) - .collect(); - - let mut qual = TimedQualifying::new(field, 1, QualMetric::MostLaps); - - // The single injected dependency: run each emitted heat against real RH. The event - // driver itself (run_event) is the same pure orchestration the core test exercises. - let mut run = |plan: &HeatPlan| -> HeatResult { - let winner = intended_winner(plan); - eprintln!( - "full-event e2e: heat {} lineup {:?} intended winner {}", - plan.heat.0, - plan.lineup.iter().map(|c| &c.0).collect::>(), - winner.0 - ); - run_real_heat(plan, &winner) - }; - - let outcome = run_event( - &mut qual, - |seeds| Box::new(SingleElim::new(seeds, 2)), - 4, - &mut run, - 64, - ); - - // Qualifying produced a full ranking over the field. - assert_eq!( - outcome.qualifying.len(), - 4, - "every pilot appears in the qualifying ranking" - ); - // The busy node ("1") banks the most laps, so it qualifies first. - assert_eq!( - outcome.qualifying[0].competitor, - CompetitorRef("1".into()), - "the busy node tops qualifying" - ); - - // The bracket ran to a single winner — the top seed, busy in every heat. - assert_eq!( - outcome.bracket.len(), - 4, - "every seed appears in the standings" - ); - assert_eq!( - outcome.bracket[0].position, 1, - "there is a single event winner" - ); - assert_eq!( - outcome.winner(), - Some(&CompetitorRef("1".into())), - "the seed given the busy stream in every heat wins the event" - ); - assert_eq!( - outcome.bracket.iter().filter(|e| e.position == 1).count(), - 1, - "exactly one competitor holds first place" - ); - - eprintln!( - "full-event e2e: qualifying {:?} → winner {}", - outcome - .qualifying - .iter() - .map(|e| &e.competitor.0) - .collect::>(), - outcome.winner().unwrap().0 - ); -} diff --git a/crates/engine/tests/heat_live.rs b/crates/engine/tests/heat_live.rs index ea1dab5..d0c5d48 100644 --- a/crates/engine/tests/heat_live.rs +++ b/crates/engine/tests/heat_live.rs @@ -2,7 +2,7 @@ //! //! Drives a full heat against a **real dockerized RotorHazard** via the shared //! [`common::run_mock_heat`] harness, then asserts — structurally — that the heat -//! ran the whole loop to `Scored`, that the recorded transitions appear in canonical +//! ran the whole loop to `Final`, that the recorded transitions appear in canonical //! order, and that the timer crossings were collected only while the heat was live //! (cross-checked against [`consumes_pass`]). This is the first consumer of the //! reusable harness; #30 (scoring), #31 (marshaling), #33–37 fold the same log. @@ -35,6 +35,7 @@ fn mock_heat_runs_to_scored_with_passes_only_while_live() { ticks_per_lap: 6, peak_rssi: 150, baseline_rssi: 70, + seed: 0, }), ), ( @@ -43,6 +44,7 @@ fn mock_heat_runs_to_scored_with_passes_only_while_live() { ticks_per_lap: 10, peak_rssi: 120, baseline_rssi: 60, + seed: 0, }), ), ]; @@ -50,11 +52,11 @@ fn mock_heat_runs_to_scored_with_passes_only_while_live() { let log = run_mock_heat(DEFAULT_PORT, HEAT, &scenario); let heat = HeatId(HEAT.to_string()); - // --- the heat reached Scored (fold the whole log back) --- + // --- the heat reached Final (fold the whole log back) --- assert_eq!( heat_state(&log, &heat), - Some(HeatState::Scored), - "the heat must fold back to Scored" + Some(HeatState::Final), + "the heat must fold back to Final" ); // --- the forward-path transitions appear, in canonical order --- @@ -75,7 +77,7 @@ fn mock_heat_runs_to_scored_with_passes_only_while_live() { HeatTransition::Armed, HeatTransition::Running, HeatTransition::Finished, - HeatTransition::Scored, + HeatTransition::Finalized, ], "the recorded transitions must be the forward heat loop, in order" ); @@ -135,15 +137,15 @@ fn mock_heat_runs_to_scored_with_passes_only_while_live() { ); // Sanity on the boundary rule the harness relies on: a pass is NOT consumed once - // the heat is Scored — so any crossing that leaked past Finished/Scored would have + // the heat is Final — so any crossing that leaked past Finished/Final would have // failed the in-loop check above (it never reaches here as Running). assert!( - !consumes_pass(HeatState::Scored, GraceWindow::default(), None), - "the grace window is closed once Scored" + !consumes_pass(HeatState::Final, GraceWindow::default(), None), + "the grace window is closed once Final" ); println!( - "mock heat e2e: {} transitions, {pass_count} passes, folded to Scored", + "mock heat e2e: {} transitions, {pass_count} passes, folded to Final", transitions.len() ); } diff --git a/crates/engine/tests/marshaling_live.rs b/crates/engine/tests/marshaling_live.rs index ecb0739..016fc67 100644 --- a/crates/engine/tests/marshaling_live.rs +++ b/crates/engine/tests/marshaling_live.rs @@ -65,6 +65,7 @@ fn marshaling_folds_corrections_over_a_real_heat_log() { ticks_per_lap: 2, peak_rssi: 150, baseline_rssi: 70, + seed: 0, }), )]; diff --git a/crates/engine/tests/multiclass_live.rs b/crates/engine/tests/multiclass_live.rs index 7fd1264..84c1b2c 100644 --- a/crates/engine/tests/multiclass_live.rs +++ b/crates/engine/tests/multiclass_live.rs @@ -74,6 +74,7 @@ fn scenario() -> Vec<(usize, String)> { ticks_per_lap: 2, peak_rssi: 180, baseline_rssi: 70, + seed: 0, }), ), (1usize, plan_csv(&scenarios::dnf(2, 6))), diff --git a/crates/engine/tests/scoring_live.rs b/crates/engine/tests/scoring_live.rs index 46ac58a..961cb3c 100644 --- a/crates/engine/tests/scoring_live.rs +++ b/crates/engine/tests/scoring_live.rs @@ -96,6 +96,7 @@ fn busier_node_places_ahead_under_timed_and_first_to_n() { ticks_per_lap: 2, peak_rssi: 180, baseline_rssi: 70, + seed: 0, }), ), (1usize, plan_csv(&scenarios::dnf(2, 6))), diff --git a/crates/engine/tests/single_elim_live.rs b/crates/engine/tests/single_elim_live.rs deleted file mode 100644 index 9c7c3ba..0000000 --- a/crates/engine/tests/single_elim_live.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! Single-elimination end-to-end test (#34) — a real bracket over real mock-RH heats. -//! -//! Drives a small single-elimination bracket (4 seeds → 2 semis → 1 final) where every -//! heat is a **real** dockerized RotorHazard heat run through the shared -//! [`common::run_mock_heat`] harness. Each round, the [`SingleElim`] 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 round by round and a single winner emerges (the top seed, which is given the -//! busy stream in every heat it flies). -//! -//! As with the scoring 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 5037 (heat e2e 5032, scoring 5033). -//! Run: -//! -//! ```sh -//! cargo test -p gridfpv-engine --features live --test single_elim_live -- --ignored --nocapture -//! ``` -#![cfg(feature = "live")] - -mod common; - -use common::run_mock_heat; - -use gridfpv_engine::format::{CompletedHeat, Generator, GeneratorStep, HeatPlan}; -use gridfpv_engine::scoring::Placement; -use gridfpv_engine::scoring::{HeatResult, WinCondition, score_events}; -use gridfpv_engine::single_elim::SingleElim; -use gridfpv_events::{CompetitorRef, SourceTime}; -use gridfpv_projection::CompetitorKey; -use gridfpv_testkit::{NodeCsv, node_csv, plan_csv, scenarios}; - -/// DISTINCT port for the single-elim e2e (heat e2e 5032, scoring 5033, adapters -/// 5030/5031). -const PORT: u16 = 5037; - -/// A continuously-lapping stream for the heat's intended winner: `node_csv` increments -/// `lap_id` for the whole file and loops at EOF, so crossings keep coming throughout the -/// live window no matter when the race actually starts. -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 a flat -/// `lap_id` for the rest of the file. It records far fewer laps than the busy stream. -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**. -/// -/// `winner` is the lineup entry intended to win; it is seated on node 0 with the busy -/// stream, every other lineup entry on a later node with a DNF stream. After scoring the -/// real heat (whose placements are keyed by `node-{i}` seat refs), we translate each -/// placement back to the bracket competitor at the same lineup position, so the result -/// the generator consumes is in *its* namespace — exactly what a real scheduler (#36) -/// would do when it maps a plan's lineup onto seats. -fn run_bracket_heat(plan: &HeatPlan, winner: &CompetitorRef) -> CompletedHeat { - // Seat the intended winner first (node 0) so it gets the busy stream; everyone else - // is a DNF node. The lineup order otherwise carries over to node order. - 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); - // Most-laps over a wide window: the busy node banks strictly more laps than the - // DNF node, so it places first. A 10-minute window is generously past the heat. - 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 (`node-{i}` → `ordered[i]`). A node that produced no live-window passes is - // absent from `scored`; such competitors are appended after the present ones, keeping - // a total order so the loser still places behind the busy winner. - let mut places: Vec = Vec::new(); - let mut seen: Vec = vec![false; ordered.len()]; - // First, the placements that appeared, in their finishing order, remapped. - 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])); - } - } - } - // Then any lineup entry that produced no passes, parked behind the finishers. - 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_bracket_runs_to_a_single_winner_over_real_heats() { - let field: Vec = ["1", "2", "3", "4"] - .iter() - .map(|n| CompetitorRef(n.to_string())) - .collect(); - // Head-to-head bracket: round 1 (two semis) → round 2 (final). The top seed is the - // intended winner of every heat it flies, so it should emerge as the bracket winner. - let intended_winner = CompetitorRef("1".into()); - let mut generator = SingleElim::new(field, 2); - - let mut completed: Vec = Vec::new(); - let mut rounds = 0; - while let GeneratorStep::Run(heats) = generator.next(&completed) { - rounds += 1; - assert!(rounds < 8, "bracket should converge well within 8 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!( - "single-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 winner sits 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 bracket winner"); - assert_eq!( - ranking[0].competitor, intended_winner, - "the seed given the busy stream in every heat wins the bracket" - ); - assert!( - ranking.iter().filter(|e| e.position == 1).count() == 1, - "exactly one competitor holds first place" - ); - eprintln!( - "single-elim e2e: winner {} after {} rounds", - ranking[0].competitor.0, rounds - ); -} diff --git a/crates/engine/tests/timed_qual_live.rs b/crates/engine/tests/timed_qual_live.rs index 27904c9..69b9906 100644 --- a/crates/engine/tests/timed_qual_live.rs +++ b/crates/engine/tests/timed_qual_live.rs @@ -72,6 +72,7 @@ fn scenario() -> Vec<(usize, String)> { ticks_per_lap: 2, peak_rssi: 180, baseline_rssi: 70, + seed: 0, }), ), (1usize, plan_csv(&scenarios::dnf(2, 6))), diff --git a/crates/engine/tests/zippyq_live.rs b/crates/engine/tests/zippyq_live.rs index fcfaace..19a6a57 100644 --- a/crates/engine/tests/zippyq_live.rs +++ b/crates/engine/tests/zippyq_live.rs @@ -54,6 +54,7 @@ fn lapper(ticks_per_lap: usize) -> String { ticks_per_lap, peak_rssi: 180, baseline_rssi: 70, + seed: 0, }) } diff --git a/crates/events/Cargo.toml b/crates/events/Cargo.toml index d735192..58135f3 100644 --- a/crates/events/Cargo.toml +++ b/crates/events/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gridfpv-events" -version = "0.1.0" +version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs index 87d3b5a..1308007 100644 --- a/crates/events/src/lib.rs +++ b/crates/events/src/lib.rs @@ -47,6 +47,20 @@ pub struct CompetitorRef(pub String); #[ts(export, export_to = "bindings/")] pub struct SessionId(pub String); +/// A GridFPV pilot's stable, event-scoped identity — the racer a source-local +/// [`CompetitorRef`] is *bound* to by a registration action (Architecture §9), never by +/// the adapter. For a basic race this is the pilot's callsign / stable id; richer pilot +/// metadata (display name, team, avatar) can layer on later. +/// +/// This is the canonical pilot handle the whole stack shares: the event model records +/// the binding ([`Event::CompetitorRegistered`]) and the wire/scope layer addresses a +/// pilot by the same type, so the log and the protocol never disagree on what a pilot id +/// is. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[serde(transparent)] +#[ts(export, export_to = "bindings/")] +pub struct PilotId(pub String); + /// A timestamp in the **source's own clock**, stored as microseconds. /// /// Lap and split durations are computed from these values, which are internally @@ -57,10 +71,11 @@ pub struct SessionId(pub String); /// and comparisons stable (no float-equality hazards in tests). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] #[serde(transparent)] -// serde flattens this to the bare `micros` integer (transparent). `#[ts(as = "i64")]` -// mirrors that on the wire: `SourceTime` resolves to a plain `number` everywhere it -// is referenced, exactly as it serialises. -#[ts(export, export_to = "bindings/", as = "i64")] +// serde flattens this to the bare `micros` integer (transparent). `#[ts(as = "f64")]` +// renders it as a plain TS `number`: our source times are microsecond counts bounded +// far below 2^53, so `number` is exact and avoids the wire/type mismatch a wide-integer +// TS mapping would introduce. +#[ts(export, export_to = "bindings/", as = "f64")] pub struct SourceTime { /// Microseconds on the source's clock. pub micros: i64, @@ -116,6 +131,102 @@ pub struct SignalContext { pub rssi_peak: Option, } +/// A contiguous run of per-tick RSSI samples captured from a signal-capable timer +/// (RotorHazard first — marshaling.html §3.2, the signal-as-evidence layer). +/// +/// The trace is the **evidence** a marshal later reviews a call against. It is captured +/// **chunked and append-incrementally** rather than one fat per-heat blob: a hardware timer +/// streams its node RSSI continuously, so chunks append as the run proceeds and interleave +/// deterministically with the [`Pass`]es in the same log — a single buffered-until-heat-end +/// event would be lost on an abort. Each chunk is a window of `rssi` samples beginning at +/// `from` on the source clock, one every `period_micros`; concatenating a competitor's chunks +/// in append order reconstructs the whole trace (the projection's job, see +/// `gridfpv_projection::signal_trace`). +/// +/// `rssi` is kept as `u16` — RotorHazard's filtered ADC counts are integers, so this is +/// compact, exact, and free of the float-equality hazards a `f32` trace would carry into the +/// deterministic fold/tests (matching the events crate's integer-time convention). +/// +/// # Fidelity bound +/// +/// RotorHazard's streaming `node_data` socket emit carries only the **latest** per-node +/// sample (`pass_peak_rssi`/`node_peak_rssi`), not a backfilled per-tick history array (that +/// lives in the request-driven `current_marshal_data`, which a live translator does not +/// subscribe to). So a chunk captured from the live stream samples at the emit cadence — one +/// `rssi` value per `node_data` tick — which is faithful to *what RH exposes live*, but is +/// coarser than the detector's internal sampling. This is the load-bearing fidelity bound +/// (marshaling.html §4) to confirm on real hardware. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct SignalChunk { + /// The timing source that produced this trace. + pub adapter: AdapterId, + /// The source-local competitor (node seat) the trace belongs to. + pub competitor: CompetitorRef, + /// The source-clock timestamp of the **first** sample in `rssi`. + pub from: SourceTime, + /// Microseconds between consecutive samples (the capture cadence). + #[ts(type = "number")] + pub period_micros: u32, + /// The RSSI samples (filtered ADC counts), oldest first, one every `period_micros`. + pub rssi: Vec, +} + +/// The enter/exit detection thresholds a signal-capable timer is configured with for a node — +/// the levels the RSSI crosses to open/close a pass (RotorHazard `enter_at_level` / +/// `exit_at_level`). Captured **once** alongside the [`SignalChunk`] trace so a marshal can +/// see *why* the timer called (or missed) a lap against the visible signal (marshaling.html +/// §3.2). One-shot per `(adapter, competitor)`; a later one supersedes (the projection keeps +/// the last). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct SignalThresholds { + /// The timing source these thresholds belong to. + pub adapter: AdapterId, + /// The source-local competitor (node seat) the thresholds apply to. + pub competitor: CompetitorRef, + /// The **enter** threshold: RSSI rising above this opens a pass. + pub enter: u16, + /// The **exit** threshold: RSSI falling below this closes the pass. + pub exit: u16, +} + +/// The **dense, full-fidelity** per-node RSSI history for a heat — the detector's own internal +/// sampling, pulled from RotorHazard's request-driven `current_marshal_data` at heat end +/// (marshaling.html §3.2, the "RSSI fidelity" risk in §4). +/// +/// Unlike the live-streamed [`SignalChunk`] (one aggregate sample per `node_data` heartbeat emit, +/// the *coarse* trace), this is the trace RotorHazard's own marshal page reviews against: every +/// per-tick sample the node recorded, with its **own** sample time. A signal-capable adapter pulls +/// it once when a heat reaches `DONE` and appends one `SignalHistory` per node. The `signal_trace` +/// projection **prefers** this dense history over the coarse [`SignalChunk`] samples for a +/// competitor when both are present (the dense trace supersedes the streaming approximation). +/// +/// # Why explicit per-sample times (not a uniform cadence) +/// +/// [`SignalChunk`] assumes a fixed `period_micros` because the live stream emits on a regular +/// heartbeat. The detector's internal history is **not** guaranteed uniform — RotorHazard reports a +/// parallel `history_times`/`history_values` pair — so this carries the per-sample times directly +/// (`times[i]` is the source-clock instant of `rssi[i]`), preserving native fidelity with **no +/// resampling** (the load-bearing fidelity caution, marshaling.html §4). Times are race-relative +/// microseconds on the same clock as [`Pass::at`] and the chunk time base, so lap markers and the +/// dense trace align. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct SignalHistory { + /// The timing source that produced this history. + pub adapter: AdapterId, + /// The source-local competitor (node seat) the history belongs to. + pub competitor: CompetitorRef, + /// The source-clock timestamp (race-relative µs) of each sample, parallel to `rssi`. Same + /// length as `rssi`; `times[i]` is when `rssi[i]` was recorded. Renders as TS `number[]` + /// (bounded far below 2^53). + #[ts(type = "number[]")] + pub times: Vec, + /// The dense per-tick RSSI samples (filtered ADC counts), parallel to `times`. + pub rssi: Vec, +} + /// A gate crossing — the one observation everything else derives from. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[ts(export, export_to = "bindings/")] @@ -130,8 +241,10 @@ pub struct Pass { /// passes that share a timestamp and survives clock adjustments; also the basis /// for reconnect deduplication. // serde skips this when `None`; `#[ts(optional)]` mirrors that as `sequence?:`. + // `#[ts(type = "number")]` renders the sequence as a plain TS `number` (it is + // bounded far below 2^53), not a `bigint`. #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] + #[ts(optional, type = "number")] pub sequence: Option, /// The gate crossed; defaults to the lap gate. #[serde(default, skip_serializing_if = "GateIndex::is_lap_gate")] @@ -140,6 +253,15 @@ pub struct Pass { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub signal: Option, + /// The heat this pass was recorded **for** — stamped by the Director's bridge at append + /// time (the adapter doesn't know the heat; the bridge does, since it only routes passes + /// while a heat is Running). `None` on a legacy log (pre-tagging), which attributes + /// positionally as before. The tag makes attribution robust against heat-span events + /// landing mid-race: scheduling/filling another heat used to close the running heat's + /// positional span and silently drop its remaining laps from the result. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub heat: Option, } // --- Race-engine vocabulary (#28) ------------------------------------------- @@ -154,20 +276,47 @@ pub struct Pass { #[ts(export, export_to = "bindings/")] pub struct HeatId(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. +/// +/// This is the **canonical** class handle the whole stack shares: the event model tags +/// a scheduled heat with the class it belongs to ([`Event::HeatScheduled`]) and the +/// wire/scope layer addresses a class by the same type +/// ([`ClassId`](../../server/scope/struct.ClassId.html), re-exported from here), so the +/// log and the protocol never disagree on what a class id is. A transparent string +/// newtype like the other event-model ids. +#[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 **round** within a class's schedule — one pass through the phase +/// (qualifying round 1, round 2, …). A transparent string newtype like [`ClassId`]; +/// the richer phase/round model lands with the scheduler, this is the stable handle a +/// scheduled heat is tagged with. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[serde(transparent)] +#[ts(export, export_to = "bindings/")] +pub struct RoundId(pub String); + /// A reference to an already-logged event by its append **offset** — the stable id /// marshaling adjudications target (e.g. "void *this* pass"). The offset is assigned /// by the storage layer when the target event was appended. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] #[serde(transparent)] -#[ts(export, export_to = "bindings/")] +// `#[ts(as = "f64")]` renders the offset as a plain TS `number`: log offsets are +// bounded far below 2^53 in our domain, so `number` is exact and avoids a wide-integer +// wire mismatch. +#[ts(export, export_to = "bindings/", as = "f64")] pub struct LogRef(pub u64); /// A transition of the heat-loop state machine (race-engine.html §2). The recorded /// transition is named for the state it enters on the forward path (Staged → Armed → -/// Running → Finished → Scored), with the off-ramps (abort/restart/discard) named for -/// the action so they stay distinct even when they land on the same state. The engine -/// validates legality against the current state. Heat *creation* is a separate event -/// ([`Event::HeatScheduled`]) — it carries the lineup, which a transition does not. +/// Running → Finished → Finalized), with the off-ramps (revert/abort/restart/discard) +/// named for the action so they stay distinct even when they land on the same state. +/// The engine validates legality against the current state. Heat *creation* is a +/// separate event ([`Event::HeatScheduled`]) — it carries the lineup, which a +/// transition does not. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] #[ts(export, export_to = "bindings/")] pub enum HeatTransition { @@ -177,28 +326,217 @@ pub enum HeatTransition { Armed, /// The race is running; passes are consumed from here (plus the grace window). Running, - /// The race closed — time elapsed or all landed. + /// The race closed — time elapsed or all landed — entering the unofficial phase. Finished, /// The result is finalized. - Scored, + Finalized, /// Results are handed to the format generator. Advanced, - /// Abandoned before scoring (Staged/Armed/Running → back a state). + /// A finalized result re-opened for correction (Final → Unofficial). + Reverted, + /// Abandoned before finalizing (Staged/Armed/Running → Scheduled, so the RD re-Stages). Aborted, - /// A running heat restarted from staging. + /// A committed heat restarted (Armed/Running/Unofficial → Scheduled, so the RD re-Stages). Restarted, - /// A scored heat discarded for a re-run. + /// A finalized heat discarded for a re-run. Discarded, } -/// A marshaling penalty applied to a competitor in a heat. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, TS)] +/// A marshaling penalty applied to a competitor in a heat (marshaling.html §3.3 — the +/// adjudication framework: DQ, penalty time *and* points, richer than RotorHazard's +/// `+time`/note model). +/// +/// The variants split by **where they land**: +/// - [`Disqualify`](Penalty::Disqualify) and [`TimeAdded`](Penalty::TimeAdded) reshape the +/// **per-heat** lap/time result (the heat scorer, `gridfpv_engine::scoring`): a DQ sinks a +/// competitor below the field, a time penalty worsens their deciding time. +/// - [`PointsDeducted`](Penalty::PointsDeducted) / [`PointsAdded`](Penalty::PointsAdded) do +/// **not** touch the per-heat lap result — they adjust the competitor's **season / event +/// standings** points, folded by the standings projection +/// (`gridfpv_server::round_engine::class_standings`), leaving the heat's lap/time/DQ intact. +/// +/// All variants are reversible via [`RulingReversed`](Event::RulingReversed) and read back on a +/// legacy log (the enum is externally tagged, so the new variants are purely additive). +/// +/// # Legacy `Disqualify` compatibility +/// +/// Adding the optional `reason` made [`Disqualify`](Penalty::Disqualify) a *struct* variant, +/// which serde would otherwise serialise as `{"Disqualify":{}}` and refuse to read from the +/// legacy bare string `"Disqualify"`. A hand-written [`Deserialize`] (see the impl below) accepts +/// **both** the legacy bare `"Disqualify"` and the struct form, and [`Serialize`] keeps emitting +/// the compact bare `"Disqualify"` when there is no reason — so old logs round-trip byte-for-byte +/// and a reason-less DQ stays on the wire exactly as before. +#[derive(Debug, Clone, PartialEq, TS)] #[ts(export, export_to = "bindings/")] pub enum Penalty { - /// Disqualified from the heat. - Disqualify, + /// Disqualified from the heat. A first-class, reversible competitor **status** (not just a + /// time effect): the scorer sinks a DQ'd competitor below every non-disqualified one and + /// flags the placement, and a [`RulingReversed`](Event::RulingReversed) of the + /// [`PenaltyApplied`](Event::PenaltyApplied) cleanly restores them. An optional `reason` + /// carries *why* — surfaced in the result + audit; default-absent so a bare `Disqualify` + /// (and every legacy DQ) reads back unchanged. + Disqualify { + /// Why the competitor was disqualified (e.g. "cut the course", "unsafe flying"). `None` + /// when no reason was recorded — the common quick-DQ, and the legacy shape. (`Penalty` + /// hand-rolls serde — see the impls below — so the optional/skip behaviour lives there; + /// `#[ts(optional)]` keeps the generated TS field `reason?:`.) + #[ts(optional)] + reason: Option, + }, /// Time added to the competitor's result, in microseconds. - TimeAdded { micros: i64 }, + TimeAdded { + #[ts(type = "number")] + micros: i64, + }, + /// **Points deducted** from the competitor's season / event **standings** (marshaling.html + /// §3.3). Distinct from a time penalty: it does *not* change the per-heat lap result — it + /// subtracts from the points the competitor accrued across the class's rounds, folded by the + /// standings projection (`class_standings`). Reversible like any ruling. + PointsDeducted { + /// How many standings points to subtract (saturating at zero). + points: u32, + }, + /// **Points added** to the competitor's season / event **standings** — the symmetric + /// counterpart of [`PointsDeducted`](Penalty::PointsDeducted) (e.g. a goodwill / appeal + /// award). Also standings-only; does not touch the per-heat lap result. + PointsAdded { + /// How many standings points to add. + points: u32, + }, +} + +impl Penalty { + /// Whether this penalty is a disqualification (regardless of any reason). The first-class + /// DQ-status predicate the scorer / UI use without matching the inner `reason`. + pub const fn is_disqualify(&self) -> bool { + matches!(self, Penalty::Disqualify { .. }) + } +} + +// `Penalty` hand-rolls serde so the legacy bare `"Disqualify"` string and a reason-carrying +// `{"Disqualify":{"reason":...}}` both round-trip, while a reason-less DQ still serialises as the +// compact bare string (see the `Penalty` doc, "Legacy `Disqualify` compatibility"). The other +// variants serialise/deserialise exactly as the derived externally-tagged form would, so adding +// `PointsDeducted` / `PointsAdded` stays purely additive on the wire. +impl Serialize for Penalty { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStructVariant; + match self { + // A reason-less DQ stays the bare `"Disqualify"` string (legacy-identical); a DQ with a + // reason serialises as the struct form so the reason rides along. + Penalty::Disqualify { reason: None } => { + serializer.serialize_unit_variant("Penalty", 0, "Disqualify") + } + Penalty::Disqualify { + reason: Some(reason), + } => { + let mut sv = serializer.serialize_struct_variant("Penalty", 0, "Disqualify", 1)?; + sv.serialize_field("reason", reason)?; + sv.end() + } + Penalty::TimeAdded { micros } => serializer.serialize_newtype_variant( + "Penalty", + 1, + "TimeAdded", + &TimeAddedRepr { micros: *micros }, + ), + Penalty::PointsDeducted { points } => serializer.serialize_newtype_variant( + "Penalty", + 2, + "PointsDeducted", + &PointsRepr { points: *points }, + ), + Penalty::PointsAdded { points } => serializer.serialize_newtype_variant( + "Penalty", + 3, + "PointsAdded", + &PointsRepr { points: *points }, + ), + } + } +} + +/// The body of a serialized `TimeAdded` — its single `micros` field, so the externally-tagged +/// wire shape stays `{"TimeAdded":{"micros":N}}` exactly as the derived form produced. +#[derive(Serialize, Deserialize)] +struct TimeAddedRepr { + micros: i64, +} + +/// The body of a serialized `PointsDeducted` / `PointsAdded` — its single `points` field. +#[derive(Serialize, Deserialize)] +struct PointsRepr { + points: u32, +} + +impl<'de> Deserialize<'de> for Penalty { + fn deserialize>(deserializer: D) -> Result { + use serde::de::{self, MapAccess, Visitor}; + use std::fmt; + + struct PenaltyVisitor; + + impl<'de> Visitor<'de> for PenaltyVisitor { + type Value = Penalty; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a Penalty: the string \"Disqualify\" or a tagged variant object") + } + + // The legacy bare string `"Disqualify"` (a unit variant on the wire). + fn visit_str(self, value: &str) -> Result { + match value { + "Disqualify" => Ok(Penalty::Disqualify { reason: None }), + other => Err(de::Error::unknown_variant(other, &["Disqualify"])), + } + } + + // The externally-tagged object form `{"Variant": }` for every variant + // (including a reason-carrying `Disqualify`). + fn visit_map>(self, mut map: A) -> Result { + let tag: String = map + .next_key()? + .ok_or_else(|| de::Error::custom("empty Penalty object"))?; + let penalty = match tag.as_str() { + "Disqualify" => Penalty::Disqualify { + reason: map.next_value::()?.reason, + }, + "TimeAdded" => Penalty::TimeAdded { + micros: map.next_value::()?.micros, + }, + "PointsDeducted" => Penalty::PointsDeducted { + points: map.next_value::()?.points, + }, + "PointsAdded" => Penalty::PointsAdded { + points: map.next_value::()?.points, + }, + other => { + return Err(de::Error::unknown_variant( + other, + &["Disqualify", "TimeAdded", "PointsDeducted", "PointsAdded"], + )); + } + }; + // Reject a trailing second key — an externally-tagged variant is a single entry. + if map.next_key::()?.is_some() { + return Err(de::Error::custom( + "Penalty object has more than one variant", + )); + } + Ok(penalty) + } + } + + deserializer.deserialize_any(PenaltyVisitor) + } +} + +/// The body of a serialized `Disqualify { reason }` — its optional `reason` field, defaulting to +/// `None` so `{"Disqualify":{}}` (an empty body) reads back as a reason-less DQ. +#[derive(Deserialize)] +struct DisqualifyRepr { + #[serde(default)] + reason: Option, } /// A canonical event in the append-only log. @@ -236,23 +574,141 @@ pub enum Event { adapter: AdapterId, competitor: CompetitorRef, }, + /// The RD bound a source-local competitor to a GridFPV pilot — the *registration* + /// action the adapter never performs itself (Architecture §9): "this timer channel + /// **is** this pilot". This is the logged binding the live and lap projections fold + /// to surface pilot identity over a bare [`CompetitorRef`]. Last registration for a + /// given `(adapter, competitor)` wins (a re-bind supersedes the earlier one); the + /// raw observations it maps over are never mutated. + CompetitorRegistered { + adapter: AdapterId, + competitor: CompetitorRef, + pilot: PilotId, + }, /// A gate crossing — the atom (see [`Pass`]). Pass(Pass), + /// A chunk of captured per-node RSSI trace (marshaling Slice 1 — the signal-as-evidence + /// plumbing, marshaling.html §3.2). An **immutable raw observation** like [`Pass`]: a + /// signal-capable adapter appends these incrementally as a heat runs, and the + /// `gridfpv_projection::signal_trace` projection folds a competitor's chunks back into a + /// contiguous trace. A timer with no signal (sim/Velocidrone) never emits this, so the + /// signal layer is simply absent there. + SignalChunk(SignalChunk), + /// The one-shot enter/exit detection thresholds for a node (see [`SignalThresholds`]). + /// Captured alongside the trace so the evidence carries the levels the timer detected + /// against; the last one per `(adapter, competitor)` wins. + SignalThresholds(SignalThresholds), + /// The **dense, full-fidelity** per-node RSSI history for a heat, pulled from RotorHazard's + /// request-driven `current_marshal_data` at heat end (see [`SignalHistory`]). Supersedes the + /// coarse streaming [`SignalChunk`] samples for its competitor in the `signal_trace` projection. + SignalHistory(SignalHistory), + // --- race-engine events (#28) --- + /// A round's **seeded field is drawn** — the one-time freeze of a carry seeding's + /// resolution (issue #334; decision D18's "one grouping decision" extended to the field). + /// + /// A round seeded **from another round's outcome** (`FromRanking` / `FromRankingRange` / + /// `FromHeatWinners` / `Combine`) records its resolved field here at **first fill**, and + /// every later read (fills, ranking, standings, dependent seeding) uses the recorded + /// draw. Without this, the seeding re-resolved live on every read — so adjudicating the + /// *source* round after this round had already raced silently rewrote who this round's + /// field "was", vanishing raced results from its ranking. Roster-derived seedings + /// (`FromRoster` / `AllChannels`) never record a draw: they stay live so late entrants + /// keep working. + RoundFieldDrawn { + /// The round whose field this freezes. + round: RoundId, + /// The resolved field, in seed order — the draw every later read replays. + field: Vec, + }, /// A heat is created with its lineup and enters the `Scheduled` state — the - /// `[*] → Scheduled` entry of the heat loop (race-engine.html §2). Minimal for - /// now: the competitors in the heat; seat/frequency assignment lands with the - /// scheduler (#36). + /// `[*] → Scheduled` entry of the heat loop (race-engine.html §2). Carries the + /// competitors in the heat and, additively, the class/round it belongs to and the + /// per-pilot frequency assignment. + /// + /// The `class`, `round`, `frequencies`, and `label` fields are **additive** and + /// default-absent: a heat scheduled without them (the free-text NewHeat path, a + /// sim race, a pre-existing log entry) reads back as `None`/empty, so older logs + /// round-trip unchanged. `frequencies` pairs each competitor with a raw-MHz channel + /// (e.g. `5800`); empty means none assigned (sim/none). `label` is the optional + /// human name a manual build-heat carries (see the field doc). HeatScheduled { heat: HeatId, lineup: Vec, + /// The class this heat runs in, where the scheduler tagged it. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + class: Option, + /// The round within the class's schedule, where the scheduler tagged it. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + round: Option, + /// Per-pilot frequency assignment in raw MHz (e.g. `5800`). Empty when none is + /// assigned (a simulator, or the free-text path that does not assign channels). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + frequencies: Vec<(CompetitorRef, u16)>, + /// An **optional human label** the RD typed when building this heat by hand. When + /// present it is the heat's display name everywhere (overriding the derived + /// "‹Round› Heat N" / tier convention); `None` for a generator-filled heat, which + /// keeps the auto-name. Additive and default-absent, so a pre-existing log (or a + /// generator heat) reads back as `None` and round-trips unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + label: Option, }, /// A heat-loop state transition appended by the engine (race-engine.html §2). HeatStateChanged { heat: HeatId, transition: HeatTransition, }, + /// The RD **manually selected the current heat** in Live control — the explicit + /// "show/control *this* heat" the console appends so the live projection follows the + /// RD's choice rather than auto-following a freshly-scheduled heat. + /// + /// Event-sourced like every other RD action: the live `current_heat` derivation folds + /// the last of these (alongside `HeatStateChanged`) to decide which heat is on the + /// timer, so a replay is deterministic. Filling a new heat only adds it to the list / + /// on-deck; focus moves on a real transition or on this explicit selection. + CurrentHeatSelected { heat: HeatId }, + /// The **start procedure fired** for a heat that just entered `Armed` (heat-lifecycle + /// redesign, Slice 2). The Director runtime chooses the randomized start delay **once**, at + /// the moment the heat is armed, and writes it here as a *fact* — so the console can cue the + /// start tone, and a replay reads the **same** delay instead of re-randomizing. The + /// subsequent `Armed → Running` [`HeatStateChanged`](Event::HeatStateChanged) is appended by + /// the runtime `delay_ms` later; together they make the auto-start deterministic on replay + /// (race-engine.html §6 — the engine/projection fold never reads a clock or rolls dice; only + /// the runtime does, at emission time). + HeatStarting { + /// The heat whose start procedure fired (it is in `Armed`). + heat: HeatId, + /// The chosen randomized start delay, in **milliseconds**, from this event to the + /// `Armed → Running` transition. Written once by the runtime; deterministic on replay. + #[ts(type = "number")] + delay_ms: u32, + }, + /// The **auto-official timer armed** for a heat that just entered `Unofficial` (marshaling + /// Slice 5 — the provisional → official lifecycle, marshaling.html §3.3). When the heat's round + /// configures a [`ProtestWindow::After`](gridfpv_engine::heat::ProtestWindow::After), the + /// Director runtime writes the **deadline** here as a *fact* — the server-clock instant at which + /// it will auto-append the `Unofficial → Final` `Finalize` — so the console can render a live + /// "auto-official in M:SS" countdown and a replay reads the **same** deadline instead of + /// recomputing it from a clock. + /// + /// Mirrors [`HeatStarting`](Event::HeatStarting): the runtime logs the chosen timing once, at + /// emission time, and the subsequent `Finalize` lands at the deadline; the engine/projection + /// fold never reads a clock (race-engine.html §6). Additive and default-absent — a heat whose + /// round has no protest window (the default) never emits this, and an older log round-trips + /// unchanged. + HeatFinalizing { + /// The heat whose protest window is open (it is in `Unofficial`). + heat: HeatId, + /// The **auto-official deadline**: the server wall-clock instant (microseconds since the + /// Unix epoch) at which the runtime appends the auto `Finalize`. The countdown the console + /// shows is `at − now`. + #[ts(type = "number")] + at: i64, + }, /// Marshaling: void a previously-detected pass, referenced by log offset. The /// projection folds it out as if it never happened — the raw [`Pass`] stays in /// the log untouched. @@ -262,9 +718,63 @@ pub enum Event { adapter: AdapterId, competitor: CompetitorRef, at: SourceTime, + /// The heat the inserted lap belongs to. Unlike a raw [`Pass`] (an untagged wire + /// observation attributed positionally), an insertion is an RD statement **about a + /// specific heat** — often a finished one being marshaled while a later heat runs — + /// so it carries the tag and the heat window routes it by tag, never by position. + /// `None` only on legacy logs / commands from before the tag existed (positional + /// attribution, the old behavior). Additive: serde defaults it, `#[ts(optional)]` + /// keeps the generated TS field `heat?:`. + #[serde(default)] + #[ts(optional)] + heat: Option, }, /// Marshaling: re-time a previously-detected pass (referenced by log offset). + /// + /// Because a lap is two consecutive passes, re-timing a pass shifts the *two* + /// adjacent lap durations that share it — the projection's `corrected_passes` + /// recomputes those durations naturally from the moved pass (no extra event). + /// + /// NOTE: FPVTrackside's alternative keeps *total race time* constant by also + /// shifting the neighbour's far boundary; that is a deferred product nuance — the + /// default here is the natural per-edit duration shift (marshaling.html §3.1). LapAdjusted { target: LogRef, at: SourceTime }, + /// Marshaling: **split** one over-long lap (the lap *ending* at `target`) into two by + /// inserting a synthetic mid-lap pass at `at` — the FPVTrackside "split" action for a + /// missed mid-lap detection (marshaling.html §3.1). + /// + /// A distinct event from [`LapInserted`](Event::LapInserted) (not sugar over it) so the + /// Slice 3 audit trail can name the action — "lap split" reads cleaner than a bare + /// insert. The projection folds it by emitting the synthetic pass into the corrected + /// stream **addressable by this event's own offset**, so it is fully reversible: a later + /// [`DetectionVoided`](Event::DetectionVoided) of this offset removes the split again + /// (and "void the void" restores it). + LapSplit { + /// The log offset of the pass that *ends* the over-long lap being split. + target: LogRef, + /// When the inserted mid-lap crossing happened, on the source clock — between the + /// `target` lap's start and `target` itself. + at: SourceTime, + }, + /// Marshaling: **throw out a single valid lap** from a competitor's *scored* count + /// (marshaling.html §3.3, the adjudication framework). The lap *ending* at `target` is a + /// **real** lap — it stays in the lap list and the audit — but it is **excluded from the + /// scored result** (the counted-lap set / best-lap / consecutive window). + /// + /// This is **distinct from [`DetectionVoided`](Event::DetectionVoided)**: a void says "this + /// detection was never real" and *removes the pass* (merging the two adjacent laps); a + /// throw-out says "this lap really happened but does not count for this competitor" and leaves + /// the lap intact, only dropping it from scoring. (It is also distinct from a future season + /// "drop-worst-round" rule — that is round-level; this is **lap-level**, within one heat.) + /// + /// The scorer excludes the lap whose **end pass** is `target` **deterministically and + /// order-independently** (a pure set membership, not an evaluation-order effect — the + /// throw-out determinism risk, marshaling-plan.html §4). Reversible like any ruling via + /// [`RulingReversed`](Event::RulingReversed) of *this* event's offset. + LapThrownOut { + /// The log offset of the pass that *ends* the lap to exclude from the scored count. + target: LogRef, + }, /// Marshaling: void an entire heat. HeatVoided { heat: HeatId }, /// Marshaling: apply a penalty to a competitor in a heat. @@ -273,6 +783,62 @@ pub enum Event { competitor: CompetitorRef, penalty: Penalty, }, + /// Marshaling: a **protest was filed** against a heat result (marshaling.html §3.3 — the + /// adjudication framework's protest workflow). An **append-only fact**: filing records that a + /// protest exists; resolving it is a *separate* [`ProtestResolved`](Event::ProtestResolved) + /// fact targeting this one. There is **no `by` / actor** — per the no-login model every action + /// is the RD at the console (filed on a pilot's behalf), so naming an actor would be false + /// precision (marshaling-plan.html §2). Reversible via [`RulingReversed`](Event::RulingReversed) + /// and rendered in the audit. + ProtestFiled { + /// The heat the protest concerns. + heat: HeatId, + /// The competitor the protest is about (whose result is contested). + competitor: CompetitorRef, + /// A free-text note describing the protest. + note: String, + }, + /// Marshaling: a **protest was resolved** — the second half of the append-only protest pair, + /// targeting the [`ProtestFiled`](Event::ProtestFiled) it closes by its log offset. The + /// `outcome` records the ruling ([`Upheld`](ProtestOutcome::Upheld) / + /// [`Denied`](ProtestOutcome::Denied) / [`Withdrawn`](ProtestOutcome::Withdrawn)). Like the + /// filing it carries no actor, is reversible, and renders in the audit. + ProtestResolved { + /// The log offset of the [`ProtestFiled`](Event::ProtestFiled) this resolves. + target: LogRef, + /// How the protest was resolved. + outcome: ProtestOutcome, + }, + /// Marshaling: **reverse a prior ruling**, referenced by its log offset — the generalized, + /// structural undo for **any** adjudication (marshaling.html §3.3 "everything reversible"). + /// + /// Originally scoped to penalties (Slice 2); now generalized (Slice 6) so a reversal can target + /// **any** ruling — a [`PenaltyApplied`](Event::PenaltyApplied) (DQ / time / points), a + /// [`LapThrownOut`](Event::LapThrownOut), a [`ProtestResolved`](Event::ProtestResolved), or a + /// [`HeatVoided`](Event::HeatVoided). The reversal is **structural** ("void the void"): the fold + /// drops the ruling at `target` from the result, and a reversal can itself be reversed. + /// + /// A distinct event from [`DetectionVoided`](Event::DetectionVoided) so the audit reads cleanly + /// — "DQ reversed" / "throw-out reversed" rather than overloading the lap-level void. + RulingReversed { + /// The log offset of the ruling to reverse (a penalty, throw-out, protest resolution, or + /// heat-void). + target: LogRef, + }, +} + +/// How a [`ProtestFiled`](Event::ProtestFiled) was resolved (marshaling.html §3.3). A small, +/// closed enum recorded by [`ProtestResolved`](Event::ProtestResolved); externally tagged on the +/// wire like the rest of the event vocabulary, so it maps to a TS string union. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum ProtestOutcome { + /// The protest was **upheld** — the contesting party's claim was accepted. + Upheld, + /// The protest was **denied** — the result stands as adjudicated. + Denied, + /// The protest was **withdrawn** before a ruling. + Withdrawn, } #[cfg(test)] @@ -287,6 +853,7 @@ mod tests { sequence: Some(3), gate: GateIndex::LAP, signal: None, + heat: None, } } @@ -367,6 +934,28 @@ mod tests { CompetitorRef("node-0".into()), CompetitorRef("node-1".into()), ], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + Event::HeatScheduled { + heat: HeatId("main-a".into()), + lineup: vec![ + CompetitorRef("node-0".into()), + CompetitorRef("node-1".into()), + ], + class: Some(ClassId("open".into())), + round: Some(RoundId("r1".into())), + frequencies: vec![ + (CompetitorRef("node-0".into()), 5658), + (CompetitorRef("node-1".into()), 5695), + ], + label: None, + }, + Event::HeatStarting { + heat: HeatId("q-1".into()), + delay_ms: 3200, }, Event::HeatStateChanged { heat: HeatId("q-1".into()), @@ -376,16 +965,25 @@ mod tests { heat: HeatId("q-1".into()), transition: HeatTransition::Aborted, }, + Event::CurrentHeatSelected { + heat: HeatId("q-2".into()), + }, Event::DetectionVoided { target: LogRef(42) }, Event::LapInserted { adapter: AdapterId("rh".into()), competitor: CompetitorRef("node-0".into()), at: SourceTime::from_micros(5_000_000), + heat: None, }, Event::LapAdjusted { target: LogRef(43), at: SourceTime::from_micros(5_100_000), }, + Event::LapSplit { + target: LogRef(44), + at: SourceTime::from_micros(5_050_000), + }, + Event::RulingReversed { target: LogRef(45) }, Event::HeatVoided { heat: HeatId("q-1".into()), }, @@ -397,7 +995,7 @@ mod tests { Event::PenaltyApplied { heat: HeatId("main-a".into()), competitor: CompetitorRef("Bee".into()), - penalty: Penalty::Disqualify, + penalty: Penalty::Disqualify { reason: None }, }, ]; for event in events { @@ -411,7 +1009,8 @@ mod tests { fn all_heat_transitions_round_trip() { use HeatTransition::*; for t in [ - Staged, Armed, Running, Finished, Scored, Advanced, Aborted, Restarted, Discarded, + Staged, Armed, Running, Finished, Finalized, Advanced, Reverted, Aborted, Restarted, + Discarded, ] { let json = serde_json::to_string(&t).unwrap(); let back: HeatTransition = serde_json::from_str(&json).unwrap(); @@ -419,10 +1018,353 @@ mod tests { } } + #[test] + fn competitor_registered_round_trips() { + // The registration binding: this source competitor *is* this pilot. + let event = Event::CompetitorRegistered { + adapter: AdapterId("rh".into()), + competitor: CompetitorRef("node-2".into()), + pilot: PilotId("acroace".into()), + }; + let json = serde_json::to_string(&event).unwrap(); + let back: Event = serde_json::from_str(&json).unwrap(); + assert_eq!(event, back); + } + + #[test] + fn pilot_id_is_transparent_on_the_wire() { + // `PilotId` is a transparent newtype — it serialises as the bare callsign string. + assert_eq!( + serde_json::to_string(&PilotId("acroace".into())).unwrap(), + "\"acroace\"" + ); + } + #[test] fn log_ref_is_a_bare_offset_on_the_wire() { // `LogRef` is transparent — it serialises as the raw offset integer, the // stable id adjudications target. assert_eq!(serde_json::to_string(&LogRef(42)).unwrap(), "42"); } + + #[test] + fn class_and_round_ids_are_transparent_on_the_wire() { + // Both newtypes serialise as the bare string, like the other event-model ids. + assert_eq!( + serde_json::to_string(&ClassId("open".into())).unwrap(), + "\"open\"" + ); + assert_eq!( + serde_json::to_string(&RoundId("r1".into())).unwrap(), + "\"r1\"" + ); + } + + #[test] + fn heat_scheduled_omits_class_round_and_frequencies_when_default() { + // A heat with no class/round/frequencies/label (the free-text NewHeat path, a + // sim race) serialises *exactly* like the pre-tag shape: the new fields are + // skipped entirely, so the wire stays byte-compatible with old logs. + let event = Event::HeatScheduled { + heat: HeatId("q-1".into()), + lineup: vec![CompetitorRef("node-0".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }; + let json = serde_json::to_string(&event).unwrap(); + assert!(!json.contains("class"), "absent class omitted: {json}"); + assert!(!json.contains("round"), "absent round omitted: {json}"); + assert!( + !json.contains("frequencies"), + "empty frequencies omitted: {json}" + ); + assert!(!json.contains("label"), "absent label omitted: {json}"); + let back: Event = serde_json::from_str(&json).unwrap(); + assert_eq!(event, back); + } + + #[test] + fn heat_scheduled_round_trips_with_the_new_fields() { + let event = Event::HeatScheduled { + heat: HeatId("main-a".into()), + lineup: vec![ + CompetitorRef("node-0".into()), + CompetitorRef("node-1".into()), + ], + class: Some(ClassId("open".into())), + round: Some(RoundId("r2".into())), + frequencies: vec![ + (CompetitorRef("node-0".into()), 5658), + (CompetitorRef("node-1".into()), 5695), + ], + label: Some("Featured Heat".into()), + }; + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("class") && json.contains("round") && json.contains("frequencies")); + assert!(json.contains("label") && json.contains("Featured Heat")); + let back: Event = serde_json::from_str(&json).unwrap(); + assert_eq!(event, back); + } + + #[test] + fn lap_split_and_ruling_reversed_round_trip() { + // The two new Slice-2 marshaling facts round-trip through the externally-tagged JSON. + let events = vec![ + Event::LapSplit { + target: LogRef(7), + at: SourceTime::from_micros(3_500_000), + }, + Event::RulingReversed { target: LogRef(9) }, + ]; + for event in events { + let json = serde_json::to_string(&event).unwrap(); + let back: Event = serde_json::from_str(&json).unwrap(); + assert_eq!(event, back); + } + } + + #[test] + fn slice6_adjudication_events_round_trip() { + // Every new Slice-6 fact round-trips through the externally-tagged JSON. + let events = vec![ + Event::LapThrownOut { target: LogRef(11) }, + Event::ProtestFiled { + heat: HeatId("main-a".into()), + competitor: CompetitorRef("AcroAce".into()), + note: "cut the chicane on lap 3".into(), + }, + Event::ProtestResolved { + target: LogRef(12), + outcome: ProtestOutcome::Upheld, + }, + Event::PenaltyApplied { + heat: HeatId("main-a".into()), + competitor: CompetitorRef("Bee".into()), + penalty: Penalty::PointsDeducted { points: 5 }, + }, + Event::PenaltyApplied { + heat: HeatId("main-a".into()), + competitor: CompetitorRef("Bee".into()), + penalty: Penalty::PointsAdded { points: 2 }, + }, + Event::PenaltyApplied { + heat: HeatId("main-a".into()), + competitor: CompetitorRef("Cee".into()), + penalty: Penalty::Disqualify { + reason: Some("unsafe flying".into()), + }, + }, + ]; + for event in events { + let json = serde_json::to_string(&event).unwrap(); + let back: Event = serde_json::from_str(&json).unwrap(); + assert_eq!(event, back); + } + } + + #[test] + fn protest_outcomes_round_trip() { + for outcome in [ + ProtestOutcome::Upheld, + ProtestOutcome::Denied, + ProtestOutcome::Withdrawn, + ] { + let json = serde_json::to_string(&outcome).unwrap(); + let back: ProtestOutcome = serde_json::from_str(&json).unwrap(); + assert_eq!(outcome, back); + } + } + + #[test] + fn disqualify_without_reason_serializes_as_the_legacy_bare_string() { + // A reason-less DQ stays byte-compatible with the legacy `"Disqualify"` wire form, so old + // consumers and old logs round-trip unchanged. A DQ *with* a reason takes the struct form. + let bare = Penalty::Disqualify { reason: None }; + assert_eq!(serde_json::to_string(&bare).unwrap(), r#""Disqualify""#); + assert_eq!( + serde_json::from_str::(r#""Disqualify""#).unwrap(), + bare + ); + + let with_reason = Penalty::Disqualify { + reason: Some("cut the course".into()), + }; + let json = serde_json::to_string(&with_reason).unwrap(); + assert_eq!(json, r#"{"Disqualify":{"reason":"cut the course"}}"#); + assert_eq!(serde_json::from_str::(&json).unwrap(), with_reason); + // An empty struct body also reads back as a reason-less DQ. + assert_eq!( + serde_json::from_str::(r#"{"Disqualify":{}}"#).unwrap(), + bare + ); + } + + #[test] + fn points_penalties_round_trip_on_the_wire() { + // The two standings-only penalties carry their `points` as a bare integer. + let deducted = Penalty::PointsDeducted { points: 7 }; + assert_eq!( + serde_json::to_string(&deducted).unwrap(), + r#"{"PointsDeducted":{"points":7}}"# + ); + assert_eq!( + serde_json::from_str::(r#"{"PointsDeducted":{"points":7}}"#).unwrap(), + deducted + ); + let added = Penalty::PointsAdded { points: 3 }; + assert_eq!( + serde_json::from_str::(r#"{"PointsAdded":{"points":3}}"#).unwrap(), + added + ); + } + + #[test] + fn time_added_wire_shape_is_unchanged() { + // Hand-rolling serde for `Penalty` must not change `TimeAdded`'s legacy wire shape. + let p = Penalty::TimeAdded { micros: 2_000_000 }; + assert_eq!( + serde_json::to_string(&p).unwrap(), + r#"{"TimeAdded":{"micros":2000000}}"# + ); + assert_eq!( + serde_json::from_str::(r#"{"TimeAdded":{"micros":2000000}}"#).unwrap(), + p + ); + } + + #[test] + fn legacy_log_without_slice6_variants_reads_back() { + // A log written before the Slice-6 facts existed carries none of them; the additive + // variants leave every pre-existing serialized event deserializing unchanged. + let legacy_penalty = r#"{"PenaltyApplied":{"heat":"m","competitor":"B","penalty":{"TimeAdded":{"micros":500000}}}}"#; + assert_eq!( + serde_json::from_str::(legacy_penalty).unwrap(), + Event::PenaltyApplied { + heat: HeatId("m".into()), + competitor: CompetitorRef("B".into()), + penalty: Penalty::TimeAdded { micros: 500_000 }, + } + ); + } + + #[test] + fn legacy_log_without_split_or_reversal_reads_back() { + // An old log written before `LapSplit`/`RulingReversed` existed carries only the + // pre-Slice-2 marshaling variants. Adding the new variants is purely additive on the + // externally-tagged `Event` enum, so every pre-existing serialized event still + // deserializes unchanged — mirrors `legacy_heat_scheduled_reads_back_with_defaults`. + let legacy_void = r#"{"DetectionVoided":{"target":42}}"#; + assert_eq!( + serde_json::from_str::(legacy_void).unwrap(), + Event::DetectionVoided { target: LogRef(42) } + ); + let legacy_adjust = r#"{"LapAdjusted":{"target":43,"at":5100000}}"#; + assert_eq!( + serde_json::from_str::(legacy_adjust).unwrap(), + Event::LapAdjusted { + target: LogRef(43), + at: SourceTime::from_micros(5_100_000), + } + ); + let legacy_penalty = + r#"{"PenaltyApplied":{"heat":"main-a","competitor":"Bee","penalty":"Disqualify"}}"#; + assert_eq!( + serde_json::from_str::(legacy_penalty).unwrap(), + Event::PenaltyApplied { + heat: HeatId("main-a".into()), + competitor: CompetitorRef("Bee".into()), + penalty: Penalty::Disqualify { reason: None }, + } + ); + } + + #[test] + fn signal_trace_events_round_trip() { + // The Slice-1 signal-as-evidence facts round-trip through externally-tagged JSON. + let events = vec![ + Event::SignalChunk(SignalChunk { + adapter: AdapterId("rotorhazard".into()), + competitor: CompetitorRef("node-0".into()), + from: SourceTime::from_micros(2_215_296), + period_micros: 100_000, + rssi: vec![70, 72, 150, 148, 71, 70], + }), + Event::SignalThresholds(SignalThresholds { + adapter: AdapterId("rotorhazard".into()), + competitor: CompetitorRef("node-0".into()), + enter: 90, + exit: 80, + }), + Event::SignalHistory(SignalHistory { + adapter: AdapterId("rotorhazard".into()), + competitor: CompetitorRef("node-0".into()), + times: vec![0, 50_000, 100_000, 150_000], + rssi: vec![70, 88, 150, 71], + }), + ]; + for event in events { + let json = serde_json::to_string(&event).unwrap(); + let back: Event = serde_json::from_str(&json).unwrap(); + assert_eq!(event, back); + } + } + + #[test] + fn legacy_log_without_signal_trace_reads_back_with_defaults() { + // A log written before `SignalChunk`/`SignalThresholds` existed carries none of the new + // variants. They are a purely additive extension of the externally-tagged `Event` enum, so + // every pre-existing serialized event still deserializes unchanged — and a Velocidrone/sim + // log (which never emits the signal facts) is byte-identical to a pre-Slice-1 log. + let legacy_pass = + r#"{"Pass":{"adapter":"velocidrone","competitor":"AcroAce","at":12500000}}"#; + assert_eq!( + serde_json::from_str::(legacy_pass).unwrap(), + Event::Pass(Pass { + adapter: AdapterId("velocidrone".into()), + competitor: CompetitorRef("AcroAce".into()), + at: SourceTime::from_micros(12_500_000), + sequence: None, + gate: GateIndex::LAP, + signal: None, + heat: None, + }) + ); + // And the new facts themselves deserialize from their on-the-wire shape. + let chunk = r#"{"SignalChunk":{"adapter":"rotorhazard","competitor":"node-1","from":5000000,"period_micros":100000,"rssi":[60,120,61]}}"#; + assert_eq!( + serde_json::from_str::(chunk).unwrap(), + Event::SignalChunk(SignalChunk { + adapter: AdapterId("rotorhazard".into()), + competitor: CompetitorRef("node-1".into()), + from: SourceTime::from_micros(5_000_000), + period_micros: 100_000, + rssi: vec![60, 120, 61], + }) + ); + } + + #[test] + fn legacy_heat_scheduled_reads_back_with_defaults() { + // A pre-existing serialized `HeatScheduled` (before the class/round/frequencies/ + // label tags existed) must still deserialize, with the new fields defaulting to + // None/empty. This is the exact JSON shape an old log on disk holds. + let legacy = r#"{"HeatScheduled":{"heat":"q-1","lineup":["node-0","node-1"]}}"#; + let back: Event = serde_json::from_str(legacy).unwrap(); + assert_eq!( + back, + Event::HeatScheduled { + heat: HeatId("q-1".into()), + lineup: vec![ + CompetitorRef("node-0".into()), + CompetitorRef("node-1".into()), + ], + class: None, + round: None, + frequencies: vec![], + label: None, + } + ); + } } diff --git a/crates/projection/Cargo.toml b/crates/projection/Cargo.toml index 23b6dcb..e3b2415 100644 --- a/crates/projection/Cargo.toml +++ b/crates/projection/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gridfpv-projection" -version = "0.1.0" +version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true @@ -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..b5d3d2e 100644 --- a/crates/projection/src/lib.rs +++ b/crates/projection/src/lib.rs @@ -17,19 +17,28 @@ //! Corrections are never mutations (architecture.html §3): the raw [`Pass`]es //! stay byte-identical in the log forever, and a marshal's ruling is a *new* //! appended event that the projection **folds in** over them. -//! [`lap_list_marshaled`] is the marshaling-aware lap projection — it takes each +//! [`corrected_passes`] is the **single home** of that fold (#39): it takes each //! event paired with its append **offset** and folds the adjudications //! ([`Event::DetectionVoided`], [`Event::LapInserted`], [`Event::LapAdjusted`]) -//! into a *corrected view* of the lap-gate passes, then derives laps from that -//! view exactly as [`lap_list`] does. [`lap_list`] is the no-adjudications case: +//! into a *corrected view* of the lap-gate passes. [`lap_list_marshaled`] is the +//! marshaling-aware lap projection — a thin consumer of [`corrected_passes`] that +//! groups that corrected view by competitor and derives laps from it exactly as +//! [`lap_list`] does — and the engine's marshaling-aware scorer +//! (`gridfpv_engine::event::score_marshaled`) consumes the *same* [`corrected_passes`] +//! output, so the void/insert/adjust logic exists once. [`lap_list`] is the no-adjudications case: //! it is a thin wrapper that assigns positional offsets and folds the same way, //! so a log with no rulings projects identically through either entry point. #![forbid(unsafe_code)] -use std::collections::BTreeMap; +pub mod recalc; -use gridfpv_events::{AdapterId, CompetitorRef, Event, Pass, SourceTime}; +use std::collections::{BTreeMap, BTreeSet}; + +use gridfpv_events::{ + AdapterId, CompetitorRef, Event, HeatId, LogRef, Pass, PilotId, SignalHistory, SourceTime, +}; use serde::{Deserialize, Serialize}; +use ts_rs::TS; /// Identifies a competitor *within a single timing source*. /// @@ -38,7 +47,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,22 +67,89 @@ impl CompetitorKey { } /// A single completed lap: the interval between two consecutive lap-gate passes. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// +/// The lap also carries the **global append offsets** ([`LogRef`]) of the two passes that +/// bound it — `start_ref` (the opening pass) and `end_ref` (the closing pass). These are the +/// *stable* log offsets a marshaling command targets (`VoidDetection`/`AdjustLap`/`SplitLap` +/// all key on a single pass's offset), so a UI that selects a lap can address the correct pass +/// without the operator hand-typing an offset (#55). They are real global offsets even when the +/// lap list is folded from a heat window — the fold is fed `(global_offset, &Event)` pairs, so +/// `end_ref`/`start_ref` are valid command targets across a multi-heat log (the heat-window +/// re-enumeration bug this fixes). +#[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, /// Lap duration in microseconds on the source clock /// (`pass[n + 1].at - pass[n].at`). Always `>= 0` for in-order passes. + /// Renders as a plain TS `number` (bounded far below 2^53). + #[ts(type = "number")] pub duration_micros: i64, + /// Source-clock timestamp (µs) of the pass that **closes** this lap — the gate-pass instant. + /// On the same clock as the signal trace's sample times (`from + i·period_micros`), so the + /// Marshaling RSSI graph can place a vertical lap marker at exactly this lap's gate pass + /// without re-deriving it from durations (Slice 4 — signal-as-evidence). + pub at: SourceTime, + /// Global append offset of the pass that **opens** this lap (the lap's start gate). + pub start_ref: LogRef, + /// Global append offset of the pass that **closes** this lap (the lap's end gate). This is + /// the natural correction target: voiding/adjusting it edits this lap's boundary, and a + /// `SplitLap` splits the over-long lap *ending* here. + pub end_ref: LogRef, } /// 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, /// Completed laps, ordered by lap number (1-based, ascending). pub laps: Vec, + /// Gate passes the RD **voided** (`DetectionVoided`, not undone), chronologically. The + /// record of removals travels WITH the lap list so every consumer shares it: the console + /// renders them struck-through in place, and threshold re-detection must NOT re-propose a + /// crossing the RD explicitly removed — the RSSI trace still shows the crossing, so without + /// this the tuner kept offering a voided lap back as "a lap to add". Additive: absent on + /// the wire when empty, so older payloads round-trip. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub voided: Vec, +} + +/// One RD-voided gate pass, as the lap list records it (see [`CompetitorLaps::voided`]). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct VoidedPass { + /// Source-clock time (µs) of the voided pass — its RAW instant (no re-time applied): the + /// removal record exists so re-detection can recognise the crossing on the trace, and the + /// trace knows nothing of a re-time. + pub at: SourceTime, + /// The voided pass's own global log offset (a stable row identity for the UI). + pub pass_ref: LogRef, + /// The **standing void event's** offset — the target a RESTORE (void-the-void) addresses. + /// For an AUTO-suppressed pass (see [`VoidReason::UnderMinLap`]) this is the pass's own + /// offset: there is no void event, and the restore path is a marshal ruling on the pass + /// itself (an [`Event::LapAdjusted`] re-asserting its raw instant — an explicit ruling + /// always outranks the floor). + pub void_ref: LogRef, + /// WHY the pass is off the lap chain — the console labels the row (and picks the restore + /// command) by this. + #[serde(default)] + pub reason: VoidReason, +} + +/// Why a pass sits on the removal record instead of the lap chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum VoidReason { + /// A marshal explicitly removed it ([`Event::DetectionVoided`]). + #[default] + Marshal, + /// The corrected fold suppressed it: it would close a lap under the round's minimum lap + /// time (D26 — a gate reflection / double-detection; timers are dumb emitters, GridFPV + /// owns lap semantics). + UnderMinLap, } impl CompetitorLaps { @@ -96,7 +173,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, @@ -109,6 +187,41 @@ impl LapList { } } +/// Fold the log's registration bindings into a `(adapter, competitor) -> pilot` map (#60). +/// +/// Each [`Event::CompetitorRegistered`] records that a source-local competitor *is* a +/// GridFPV pilot (Architecture §9). A competitor is keyed by its per-source +/// [`CompetitorKey`] — a bare [`CompetitorRef`] is only meaningful relative to its adapter +/// — and **last registration wins**: a later re-bind of the same `(adapter, competitor)` +/// supersedes the earlier one. The fold is pure and order-preserving, so replaying the +/// same log yields the same mapping. Competitors with no registration are simply absent — +/// they still appear by their bare [`CompetitorRef`] in the projections that consume this. +pub fn registrations<'a, I>(events: I) -> BTreeMap +where + I: IntoIterator, +{ + let mut bindings = BTreeMap::new(); + for event in events { + if let Event::CompetitorRegistered { + adapter, + competitor, + pilot, + } = event + { + // Insert (overwriting any earlier binding) — log order is append order, so the + // last writer for a given competitor wins. + bindings.insert( + CompetitorKey { + adapter: adapter.clone(), + competitor: competitor.clone(), + }, + pilot.clone(), + ); + } + } + bindings +} + /// Fold a sequence of events into the lap-list read model. /// /// Only [`Event::Pass`]es over the **lap gate** ([`is_lap_gate`]) contribute; @@ -147,27 +260,25 @@ where lap_list_marshaled(events.into_iter().enumerate().map(|(i, e)| (i as u64, e))) } -/// A lap-gate pass in the **corrected view** the marshaling fold builds. +/// Fold a sequence of `(offset, event)` pairs into the **corrected lap-gate pass +/// stream**, applying every marshaling adjudication keyed on its target's append +/// **offset** (#31). /// -/// It is never a mutation of a raw [`Pass`] — it is a derived datum carrying just -/// the `(adapter, competitor, at, sequence)` the lap derivation needs, sourced -/// either from a raw `Pass` (possibly re-timed by a [`Event::LapAdjusted`]) or -/// synthesised from a [`Event::LapInserted`]. The raw log is untouched. -#[derive(Debug, Clone)] -struct CorrectedPass { - competitor: CompetitorKey, - at: SourceTime, - sequence: Option, -} - -/// Fold a sequence of `(offset, event)` pairs into the lap-list read model, -/// applying marshaling adjudications keyed on the target's append **offset** (#31). +/// This is the *single source of truth* for the void/insert/adjust marshaling fold. +/// Both [`lap_list_marshaled`] (which groups these passes by competitor and derives +/// laps) and the engine's marshaling-aware scorer +/// (`gridfpv_engine::event::score_marshaled`) consume this one function — the fold +/// is implemented here, once, and nowhere else (#39). /// -/// This is the marshaling-aware sibling of [`lap_list`]. Each event is paired with -/// its append [`LogRef`](gridfpv_events::LogRef) offset; rulings reference the raw -/// event they correct by that offset. The fold builds a **corrected view** of the -/// lap-gate passes and then derives laps from it exactly like [`lap_list`] — the -/// raw [`Pass`]es in the input are never mutated (architecture.html §3). +/// Each event is paired with its append [`LogRef`](gridfpv_events::LogRef) offset; +/// rulings reference the raw event they correct by that offset. The result is a fresh +/// `Vec<(u64, Pass)>` of the surviving lap-gate passes (synthetic inserts included, re-timed +/// passes moved to their new instant), in **offset order**, each paired with the **global +/// append offset** that addresses it for a future correction — a raw/inserted/split pass's +/// own offset (the split's synthetic pass is addressable by the split event's offset, exactly +/// as "void the void" already relies on). The raw [`Pass`]es in the input are never mutated +/// (architecture.html §3); callers re-group/re-order as needed and may carry the offset onto +/// the projection (e.g. [`Lap::end_ref`]) so a UI can target the right pass. /// /// # Adjudications folded /// @@ -177,7 +288,13 @@ struct CorrectedPass { /// a synthetic lap-gate pass for that competitor at `at` (a lap the timer missed). /// The insert's own offset becomes a valid `target` for a later ruling. /// - [`Event::LapAdjusted { target, at }`](Event::LapAdjusted) — re-time the pass -/// at `target` offset to `at`. +/// at `target` offset to `at`. Because a lap is two consecutive passes, this shifts +/// *both* adjacent lap durations sharing the moved pass (the duration recompute is +/// structural — no extra event needed). +/// - [`Event::LapSplit { target, at }`](Event::LapSplit) — split the over-long lap +/// *ending* at the `target` pass by adding a synthetic mid-lap pass at `at`, attributed +/// to the target pass's competitor. Like an insert, the split's own offset is a valid +/// `target` for a later ruling (so it is reversible via "void the void"). /// /// # Offsets and last-writer-wins /// @@ -208,11 +325,38 @@ struct CorrectedPass { /// /// # Heat / result-level rulings /// -/// [`Event::HeatVoided`] and [`Event::PenaltyApplied`] are *not* lap-level — they -/// reshape the heat result, not the per-competitor lap list — so the lap projection -/// ignores them here. They are consumed by scoring/results (#30, #33+), which fold -/// the same log alongside this lap view. -pub fn lap_list_marshaled<'a, I>(events: I) -> LapList +/// [`Event::HeatVoided`], [`Event::PenaltyApplied`], and [`Event::RulingReversed`] are +/// *not* lap-level — they reshape the heat result, not the per-competitor lap list — so +/// this fold ignores them. They are consumed by scoring/results (#30, #33+), which fold +/// the same log alongside this corrected view. +pub fn corrected_passes<'a, I>(events: I) -> Vec<(u64, Pass)> +where + I: IntoIterator, +{ + corrected_and_voided_passes(events).0 +} + +/// [`corrected_passes`] under a round's **minimum-lap floor** (D26) — the scoring-path +/// sibling of [`lap_list_marshaled_with_floor`], so results and the lap list can never +/// disagree about a suppressed pass. +pub fn corrected_passes_with_floor<'a, I>( + events: I, + min_lap_micros: Option, +) -> Vec<(u64, Pass)> +where + I: IntoIterator, +{ + corrected_and_voided_passes_with_floor(events, min_lap_micros).0 +} + +/// One removed pass as the fold emits it: +/// `(pass offset, restore-target offset, pass, why)`. +pub type VoidedEmit = (u64, u64, Pass, VoidReason); + +/// [`corrected_passes`] plus the passes the RD **voided** (and did not un-void), each resolved +/// to its concrete pass (re-time applied) with its own offset — the shared removal record the +/// lap list carries so re-detection never re-proposes an explicitly-removed crossing. +pub fn corrected_and_voided_passes<'a, I>(events: I) -> (Vec<(u64, Pass)>, Vec) where I: IntoIterator, { @@ -220,17 +364,17 @@ where // raw lap-gate passes and the adjudications themselves — plus the rulings to // apply. We resolve targets against this map so "void the void" (a ruling whose // target is another ruling) and last-writer-wins both fall out of offset order. - #[derive(Clone)] enum Entry<'a> { /// A raw lap-gate pass observed by an adapter (never mutated). RawPass(&'a Pass), /// A synthetic lap-gate pass inserted by marshaling. - Inserted { - competitor: CompetitorKey, - at: SourceTime, - }, + Inserted(Pass), /// A re-time ruling: the target pass's `at` is overridden to this value. Adjusted { target: u64, at: SourceTime }, + /// A split ruling: insert a synthetic mid-lap pass at `at`, attributed to the + /// competitor whose lap *ends* at the `target` pass. Resolved to a concrete + /// [`Pass`] in the second loop, where the target's adapter/competitor are known. + Split { target: u64, at: SourceTime }, /// A void ruling: the target entry is dropped from the corrected view. Voided { target: u64 }, } @@ -245,16 +389,22 @@ where adapter, competitor, at, + // The heat tag routes the insertion into the right heat *window* upstream; + // the synthetic pass carries it through so tag-aware folds agree. + heat, } => { entries.insert( offset, - Entry::Inserted { - competitor: CompetitorKey { - adapter: adapter.clone(), - competitor: competitor.clone(), - }, + Entry::Inserted(Pass { + adapter: adapter.clone(), + competitor: competitor.clone(), at: *at, - }, + // A synthetic pass carries no source sequence; ordered by `at`. + sequence: None, + gate: gridfpv_events::GateIndex::LAP, + signal: None, + heat: heat.clone(), + }), ); } Event::LapAdjusted { target, at } => { @@ -269,8 +419,21 @@ where Event::DetectionVoided { target } => { entries.insert(offset, Entry::Voided { target: target.0 }); } - // Splits, lifecycle, heat transitions, and the heat/result-level - // rulings (`HeatVoided`, `PenaltyApplied`) never touch the lap view. + Event::LapSplit { target, at } => { + // A split inserts a synthetic mid-lap pass at `at` for the competitor whose + // lap (the one *ending* at `target`) is being split — so the synthetic pass + // carries the target pass's adapter/competitor. It is addressable by *this* + // event's offset (just like an insert), so "void the void" reverses it. + entries.insert( + offset, + Entry::Split { + target: target.0, + at: *at, + }, + ); + } + // Lifecycle, heat transitions, and the heat/result-level rulings + // (`HeatVoided`, `PenaltyApplied`, `RulingReversed`) never touch the lap view. _ => {} } } @@ -283,19 +446,29 @@ where // (we process offsets ascending, so a later ruling overwrites an earlier one). let mut voided: BTreeMap = BTreeMap::new(); let mut retime: BTreeMap = BTreeMap::new(); - for (_offset, entry) in entries.iter() { + // Which VOID EVENT (its own offset) currently holds each base pass voided — the target a + // restore (void-the-void) addresses; carried onto the removal record for the UI. + let mut void_source: BTreeMap = BTreeMap::new(); + for (entry_offset, entry) in entries.iter() { match entry { - Entry::RawPass(_) | Entry::Inserted { .. } => {} + // Passes (raw, inserted, or split) carry no ruling of their own; the split is + // resolved to a concrete pass in the emit loop. A void/adjust *targeting* a split + // is handled below via `entries.get(target)` falling into the pass arms. + Entry::RawPass(_) | Entry::Inserted(_) | Entry::Split { .. } => {} Entry::Adjusted { target, at } => { // Re-time the target raw/inserted pass, and un-void it: an adjust is // the newest ruling on that target, so it supersedes an earlier void. voided.insert(*target, false); + void_source.remove(target); retime.insert(*target, *at); } Entry::Voided { target } => { // Void the target. If the target is itself a ruling, supersede it: // voiding an adjust cancels its re-time (revert to the raw `at`); - // voiding a void un-voids *that* void's target. + // voiding a void un-voids *that* void's target — and the chain WALKS, + // so a depth-3 "void the un-void" re-voids the base pass (each link + // flips the parity; the old two-level special case silently no-opped + // at depth 3, breaking last-writer-wins). match entries.get(target) { Some(Entry::Adjusted { target: inner_target, @@ -306,57 +479,251 @@ where // target present (the adjust, not the pass, was voided). retime.remove(inner_target); } - Some(Entry::Voided { - target: inner_target, - }) => { - // Void the void: resurrect the originally-voided target. - voided.insert(*inner_target, false); + Some(Entry::Voided { .. }) => { + // Walk the void chain to the base (non-void) target, flipping + // the intended state at each link: void(void(P)) restores P, + // void(void(void(P))) re-voids it, and so on. + let mut cursor = *target; + let mut state = true; // what THIS event wants for the base + while let Some(Entry::Voided { target: inner }) = entries.get(&cursor) { + state = !state; + cursor = *inner; + } + voided.insert(cursor, state); + if state { + void_source.insert(cursor, *entry_offset); + } else { + void_source.remove(&cursor); + } } // Voiding a raw pass or an inserted pass simply drops it. _ => { voided.insert(*target, true); + void_source.insert(*target, *entry_offset); } } } } } - // Build the corrected view: every raw/inserted pass that survived voiding, - // with any re-time applied. Group by competitor for lap derivation. - let mut by_competitor: BTreeMap> = BTreeMap::new(); + // Emit the passes (raw + inserted + split-synthetic) with any re-time applied, in offset + // order, each paired with the global offset that addresses it for a future correction — + // surviving passes into `out`, RD-voided ones into `voided_out` (the shared removal + // record); callers re-group and re-order them as needed. + let mut out: Vec<(u64, Pass)> = Vec::new(); + let mut voided_out: Vec = Vec::new(); + let mut scratch: Vec<(u64, Pass)> = Vec::new(); for (offset, entry) in entries.iter() { - if voided.get(offset).copied().unwrap_or(false) { - continue; + let is_voided = voided.get(offset).copied().unwrap_or(false); + scratch.clear(); + let sink: &mut Vec<(u64, Pass)> = if is_voided { &mut scratch } else { &mut out }; + match entry { + Entry::RawPass(pass) => { + let mut p = (*pass).clone(); + // A VOIDED pass keeps its RAW instant: the removal record exists so + // re-detection can recognise the crossing on the trace, and the trace + // knows nothing of a re-time (an adjusted-then-voided pass would + // otherwise leave the suppression zone at the wrong instant). + if !is_voided { + if let Some(at) = retime.get(offset) { + p.at = *at; + } + } + sink.push((*offset, p)); + } + Entry::Inserted(pass) => { + let mut p = pass.clone(); + if !is_voided { + if let Some(at) = retime.get(offset) { + p.at = *at; + } + } + sink.push((*offset, p)); + } + Entry::Split { target, at } => { + // Attribute the synthetic mid-lap pass to the competitor whose lap ends at + // `target` (the target pass's adapter/competitor). A later adjust of *this* + // split's offset re-times the synthetic pass; a void drops it. If the target + // pass is unknown (a dangling ref — the command layer rejects these), skip. + // The synthetic pass is addressable by *this* split's own offset. + // Resolve the source pass RECURSIVELY: a split may target another split's + // synthetic pass (splitting the second half of a twice-missed stretch) — + // the old single-step lookup silently skipped it while the audit showed + // the split as landed. + let mut src_offset = *target; + let src = loop { + match entries.get(&src_offset) { + Some(Entry::RawPass(p)) => break Some((*p).clone()), + Some(Entry::Inserted(p)) => break Some(p.clone()), + Some(Entry::Split { target: inner, .. }) => src_offset = *inner, + _ => break None, + } + }; + if let Some(src) = src { + sink.push(( + *offset, + Pass { + adapter: src.adapter, + competitor: src.competitor, + at: retime.get(offset).copied().unwrap_or(*at), + sequence: None, + gate: gridfpv_events::GateIndex::LAP, + signal: None, + heat: src.heat, + }, + )); + } + } + Entry::Adjusted { .. } | Entry::Voided { .. } => {} } - let corrected = match entry { - Entry::RawPass(pass) => CorrectedPass { - competitor: CompetitorKey::from_pass(pass), - at: retime.get(offset).copied().unwrap_or(pass.at), - sequence: pass.sequence, - }, - Entry::Inserted { competitor, at } => CorrectedPass { - competitor: competitor.clone(), - // An inserted lap can itself be re-timed by a later adjust. - at: retime.get(offset).copied().unwrap_or(*at), - // Synthetic passes carry no source sequence; ordered by `at`. - sequence: None, - }, - // Rulings are not passes in the view. - Entry::Adjusted { .. } | Entry::Voided { .. } => continue, - }; + if is_voided { + let void_ref = void_source.get(offset).copied().unwrap_or(*offset); + for (o, p) in scratch.drain(..) { + voided_out.push((o, void_ref, p, VoidReason::Marshal)); + } + } + } + (out, voided_out) +} + +/// [`corrected_and_voided_passes`] with the round's **minimum-lap floor** applied (D26). +/// +/// After the marshaling corrections fold, each competitor's surviving chain is walked +/// chronologically: a **raw, unruled** pass that would close a lap shorter than +/// `min_lap_micros` is AUTO-SUPPRESSED — moved onto the removal record with +/// [`VoidReason::UnderMinLap`] (its restore target is itself; a marshal re-time exempts it). +/// Marshal-created passes (inserted, split-synthetic) and re-timed passes are NEVER +/// suppressed: an explicit ruling outranks the floor. `None`/`0` floor ⇒ identical to the +/// plain fold, so rounds predating the setting keep their results bit-identical. +pub fn corrected_and_voided_passes_with_floor<'a, I>( + events: I, + min_lap_micros: Option, +) -> (Vec<(u64, Pass)>, Vec) +where + I: IntoIterator, +{ + // The plain fold needs to tell us which surviving passes are raw-and-unruled; re-derive + // that from the events here so the core fold stays untouched. Collect first (two passes + // over the data, but windows are per-heat and small). + let pairs: Vec<(u64, &Event)> = events.into_iter().collect(); + let (surviving, mut voided) = corrected_and_voided_passes(pairs.iter().copied()); + let Some(floor) = min_lap_micros.filter(|f| *f > 0) else { + return (surviving, voided); + }; + + // A pass is EXEMPT from the floor when a marshal shaped it: inserted or split-synthetic + // by construction, or re-timed by a standing (un-voided) adjust. + let mut exempt: BTreeSet = BTreeSet::new(); + for (offset, event) in &pairs { + match event { + Event::LapInserted { .. } | Event::LapSplit { .. } => { + exempt.insert(*offset); + } + Event::LapAdjusted { target, .. } => { + exempt.insert(target.0); + } + _ => {} + } + } + + // Walk each competitor's chain in time order, keep-first: a too-close successor that is + // not marshal-blessed drops to the removal record. + let mut by_competitor: BTreeMap> = BTreeMap::new(); + for (offset, pass) in surviving { + by_competitor + .entry(CompetitorKey::from_pass(&pass)) + .or_default() + .push((offset, pass)); + } + let mut out: Vec<(u64, Pass)> = Vec::new(); + for (_, mut chain) in by_competitor { + chain.sort_by_key(|(offset, p)| (p.at, *offset)); + let mut last_kept: Option = None; + for (offset, pass) in chain { + let too_close = + last_kept.is_some_and(|prev| pass.at.micros.saturating_sub(prev.micros) < floor); + if too_close && !exempt.contains(&offset) { + voided.push((offset, offset, pass, VoidReason::UnderMinLap)); + } else { + last_kept = Some(pass.at); + out.push((offset, pass)); + } + } + } + out.sort_by_key(|(offset, _)| *offset); + (out, voided_out_sorted(voided)) +} + +/// Stable ordering for the removal record (offset order, like the surviving stream). +fn voided_out_sorted(mut voided: Vec) -> Vec { + voided.sort_by_key(|(offset, _, _, _)| *offset); + voided +} + +/// Fold a sequence of `(offset, event)` pairs into the lap-list read model, +/// applying marshaling adjudications keyed on the target's append **offset** (#31). +/// +/// This is the marshaling-aware sibling of [`lap_list`]. It is a thin consumer of +/// [`corrected_passes`] — the single home of the void/insert/adjust fold (#39): it +/// takes that corrected lap-gate pass stream, groups it by `(adapter, competitor)`, +/// orders each group, and derives laps exactly like [`lap_list`]. The raw [`Pass`]es +/// in the input are never mutated (architecture.html §3). +/// +/// See [`corrected_passes`] for the adjudications folded, the offset/last-writer-wins +/// semantics, and the "void the void" cases. +pub fn lap_list_marshaled<'a, I>(events: I) -> LapList +where + I: IntoIterator, +{ + lap_list_marshaled_with_floor(events, None) +} + +/// [`lap_list_marshaled`] under a round's **minimum-lap floor** (D26): auto-suppressed passes +/// land on each competitor's removal record with [`VoidReason::UnderMinLap`]. +pub fn lap_list_marshaled_with_floor<'a, I>(events: I, min_lap_micros: Option) -> LapList +where + I: IntoIterator, +{ + // Group the corrected pass stream by competitor and derive laps. The fold itself + // lives in `corrected_passes`; here we only project it into the lap-list view. Each + // pass keeps the global offset that addresses it, so the derived laps carry their + // `start_ref`/`end_ref` command targets. + let (surviving, voided) = corrected_and_voided_passes_with_floor(events, min_lap_micros); + let mut by_competitor: BTreeMap> = BTreeMap::new(); + for (offset, pass) in surviving { + by_competitor + .entry(CompetitorKey::from_pass(&pass)) + .or_default() + .push((offset, pass)); + } + // The RD-voided passes, grouped the same way — a competitor may have voids but no + // surviving laps (every crossing removed), so they seed the map too. + let mut voided_by_competitor: BTreeMap> = BTreeMap::new(); + for (offset, void_offset, pass, reason) in voided { by_competitor - .entry(corrected.competitor.clone()) + .entry(CompetitorKey::from_pass(&pass)) + .or_default(); + voided_by_competitor + .entry(CompetitorKey::from_pass(&pass)) .or_default() - .push(corrected); + .push(VoidedPass { + at: pass.at, + pass_ref: LogRef(offset), + void_ref: LogRef(void_offset), + reason, + }); } let competitors = by_competitor .into_iter() .map(|(competitor, mut passes)| { - passes.sort_by_key(corrected_order_key); + passes.sort_by_key(|(_, p)| corrected_order_key(p)); + let mut voided = voided_by_competitor.remove(&competitor).unwrap_or_default(); + voided.sort_by_key(|v| v.at); CompetitorLaps { competitor, laps: laps_from_corrected(&passes), + voided, } }) .collect(); @@ -374,23 +741,445 @@ where /// [`lap_list`]: when there are no rulings, a source either numbers its passes /// monotonically in step with `at` or carries no sequence at all, so ordering by /// `at` yields the same lap list. -fn corrected_order_key(pass: &CorrectedPass) -> (SourceTime, bool, Option) { +fn corrected_order_key(pass: &Pass) -> (SourceTime, bool, Option) { (pass.at, pass.sequence.is_none(), pass.sequence) } -/// Turn an ordered run of corrected lap-gate passes into laps: `K` passes ⇒ -/// `K - 1` laps, each spanning a consecutive pair. -fn laps_from_corrected(passes: &[CorrectedPass]) -> Vec { +/// Turn an ordered run of corrected lap-gate passes (each carrying its global offset) into +/// laps: `K` passes ⇒ `K - 1` laps, each spanning a consecutive pair. The lap's +/// `start_ref`/`end_ref` are the global offsets of the opening/closing pass — the stable +/// command targets a UI uses to address this lap (the end pass is the natural target for +/// void/adjust/split). +fn laps_from_corrected(passes: &[(u64, Pass)]) -> Vec { passes .windows(2) .enumerate() - .map(|(idx, pair)| Lap { - number: (idx + 1) as u32, - duration_micros: pair[1].at.micros_since(pair[0].at), + .map(|(idx, pair)| { + let (start_off, start_pass) = &pair[0]; + let (end_off, end_pass) = &pair[1]; + Lap { + number: (idx + 1) as u32, + duration_micros: end_pass.at.micros_since(start_pass.at), + at: end_pass.at, + start_ref: LogRef(*start_off), + end_ref: LogRef(*end_off), + } }) .collect() } +/// The kind of a marshaling audit entry — *what sort of action* a logged fact was (#55). +/// +/// Derived purely from the event **type**, this is the "defensible results" surface: every +/// marshaling correction is a recorded, attributable, reversible fact (marshaling.html §3.3). +/// The automatic timer pass is included as [`AuditKind::Pass`] so the panel can distinguish a +/// *human ruling* from an *automatic detection* — but note `marshaling_log` only folds the +/// human rulings into entries (a heat has far too many passes to list); `Pass` exists so a +/// future "show automatic detections too" toggle is an additive consumer, not a model change. +/// +/// There is deliberately **no actor / who**: per the no-login decision every change is implicitly +/// the RD, so naming an actor would be a false precision (marshaling.html §3.3, the audit records +/// what-changed-when, and the single-RD trust model supplies the who). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum AuditKind { + /// A detection was voided (a phantom lap removed, or a "void the void" undo). + Voided, + /// A missed lap was inserted. + Inserted, + /// A lap's time was adjusted (re-timed). + Adjusted, + /// An over-long lap was split into two. + Split, + /// A penalty (DQ, added time, or points) was applied to a competitor. + PenaltyApplied, + /// A valid lap was thrown out of a competitor's scored count (not a void — the lap stays real). + LapThrownOut, + /// A protest was filed against a heat result. + ProtestFiled, + /// A filed protest was resolved (upheld / denied / withdrawn). + ProtestResolved, + /// A prior ruling (a penalty, throw-out, protest resolution, or heat-void) was reversed. + RulingReversed, + /// The whole heat was voided. + HeatVoided, + /// An automatic timer detection (NOT a marshal action). Folded only when a consumer asks for + /// the raw stream; `marshaling_log` omits these so the audit reads as a ruling history. + Pass, +} + +/// A single reverse-chronological marshaling audit entry (#55): *what changed, when, what kind*. +/// +/// A thin, render-ready fact derived from one logged marshaling event. `at` is the event's +/// `recorded_at` (the server wall-clock instant the log received it), so the panel can show +/// "when"; `summary` is a short human string ("Lap split", "DQ applied"); `kind` drives the visual +/// treatment. There is no actor field by design (see [`AuditKind`]). +/// +/// `competitor` carries the **structured** competitor ref the action targeted (when it targets one), +/// kept *out* of `summary` on purpose: the ref is a source-local handle (a pilot id, a node seat), +/// not a friendly name, so the client resolves it to the pilot's **callsign** and composes the final +/// line (e.g. prepends "Ace · " to "DQ applied"). A server-baked `summary` cannot be re-resolved, so +/// the name lives in this field, not in the string (the Marshaling raw-id bug, #214 follow-up). It is +/// `None` for the lap-/heat-addressed actions that name no competitor (void, split, heat-void, …). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct AuditEntry { + /// What kind of marshaling action this was — derived from the event type. + pub kind: AuditKind, + /// When the log received this fact (microseconds since the Unix epoch), if recorded. + /// `None` when the append carried no arrival timestamp (e.g. a replay with none supplied). + #[ts(type = "number | null")] + pub at: Option, + /// The global append offset of this fact — a stable identity for the entry (and what a + /// later "reverse this" would target). Lets the UI key the list deterministically. + pub at_ref: LogRef, + /// The competitor this action targeted, as a **structured ref** the client resolves to a + /// callsign and composes into the displayed line. `None` for actions that name no competitor. + pub competitor: Option, + /// A short human-readable description of the change — **without** the raw competitor ref (that + /// is carried structured in `competitor` so the client can show the resolved callsign instead). + pub summary: String, +} + +/// Fold a **heat-scoped** sequence of `(recorded_at, offset, &Event)` into the marshaling +/// audit trail (#55), newest first. +/// +/// This is the "defensible results" panel's projection: it walks the heat's events and emits one +/// [`AuditEntry`] per **marshaling action** (the human rulings — void/insert/adjust/split, penalty, +/// reversal, heat-void), in **reverse-chronological** (offset-descending) order. Automatic passes +/// are *not* emitted (a heat has too many to list, and they are not rulings); they fold into the +/// lap list instead. The fold is pure and deterministic — folding the same heat window twice yields +/// the same trail — so it recomputes from the log like every other projection. +/// +/// `events` must already be scoped to the heat (e.g. via the server's heat window) so the audit +/// reflects only this heat's rulings; `heat` is carried so the heat-addressed rulings +/// ([`Event::PenaltyApplied`], [`Event::HeatVoided`]) that name a *different* heat are excluded +/// even if they happen to fall inside the window. +pub fn marshaling_log<'a, I>(events: I, heat: &HeatId) -> Vec +where + I: IntoIterator, u64, &'a Event)>, +{ + let mut entries: Vec = Vec::new(); + for (at, offset, event) in events { + // `competitor` carries the structured ref the client resolves to a callsign; it is kept OUT + // of `summary` (the server can't resolve a friendly name; the client composes the final line). + let (kind, competitor, summary) = match event { + Event::DetectionVoided { target } => ( + AuditKind::Voided, + None, + format!("Detection voided (ref {})", target.0), + ), + Event::LapInserted { + competitor, at: t, .. + } => ( + AuditKind::Inserted, + Some(competitor.clone()), + format!("Lap inserted at {}", fmt_secs(*t)), + ), + Event::LapAdjusted { target, at: t } => ( + AuditKind::Adjusted, + None, + format!("Lap re-timed (ref {}) to {}", target.0, fmt_secs(*t)), + ), + Event::LapSplit { target, at: t } => ( + AuditKind::Split, + None, + format!("Lap split (ref {}) at {}", target.0, fmt_secs(*t)), + ), + Event::PenaltyApplied { + heat: h, + competitor, + penalty, + } if h == heat => ( + AuditKind::PenaltyApplied, + Some(competitor.clone()), + fmt_penalty(penalty), + ), + Event::LapThrownOut { target } => ( + AuditKind::LapThrownOut, + None, + format!("Lap thrown out (ref {})", target.0), + ), + Event::ProtestFiled { + heat: h, + competitor, + note, + } if h == heat => ( + AuditKind::ProtestFiled, + Some(competitor.clone()), + format!("Protest filed: {note}"), + ), + Event::ProtestResolved { target, outcome } => ( + AuditKind::ProtestResolved, + None, + format!("Protest {} (ref {})", fmt_outcome(*outcome), target.0), + ), + Event::RulingReversed { target } => ( + AuditKind::RulingReversed, + None, + format!("Ruling reversed (ref {})", target.0), + ), + Event::HeatVoided { heat: h } if h == heat => { + (AuditKind::HeatVoided, None, "Heat voided".to_string()) + } + // Passes and lifecycle/heat-loop events are not marshaling actions — skip them. + _ => continue, + }; + entries.push(AuditEntry { + kind, + at, + at_ref: LogRef(offset), + competitor, + summary, + }); + } + // Reverse-chronological: newest action first. Offset is append order, so descending offset + // is descending time. + entries.reverse(); + entries +} + +/// Format a `SourceTime` as whole seconds for an audit summary ("4.000s"). +fn fmt_secs(t: SourceTime) -> String { + let micros = t.micros_since(SourceTime::from_micros(0)); + format!("{:.3}s", micros as f64 / 1_000_000.0) +} + +/// Format a [`Penalty`](gridfpv_events::Penalty) for an audit summary. +fn fmt_penalty(penalty: &gridfpv_events::Penalty) -> String { + match penalty { + gridfpv_events::Penalty::Disqualify { reason } => match reason { + Some(r) => format!("DQ applied ({r})"), + None => "DQ applied".to_string(), + }, + gridfpv_events::Penalty::TimeAdded { micros } => { + format!("+{:.3}s penalty", *micros as f64 / 1_000_000.0) + } + gridfpv_events::Penalty::PointsDeducted { points } => { + format!("-{points} points") + } + gridfpv_events::Penalty::PointsAdded { points } => { + format!("+{points} points") + } + } +} + +/// Format a [`ProtestOutcome`](gridfpv_events::ProtestOutcome) for an audit summary. +fn fmt_outcome(outcome: gridfpv_events::ProtestOutcome) -> &'static str { + match outcome { + gridfpv_events::ProtestOutcome::Upheld => "upheld", + gridfpv_events::ProtestOutcome::Denied => "denied", + gridfpv_events::ProtestOutcome::Withdrawn => "withdrawn", + } +} + +// --- Signal trace (marshaling Slice 1 — signal-as-evidence) ----------------------------------- + +/// One competitor's reconstructed RSSI trace within a heat: the concatenated samples plus the +/// enter/exit thresholds the timer detected against (marshaling.html §3.2). +/// +/// The `samples` are the per-tick RSSI values in capture order; `from`/`period_micros` carry the +/// time base of the **first** chunk so a UI can place each sample on the source clock (chunks are +/// captured back-to-back at a fixed cadence, so the first chunk's base plus the running index +/// reconstructs every sample's time — see [`signal_trace`] for the contiguity it assumes). +/// `enter`/`exit` are the last thresholds seen for this competitor, `None` until one is captured. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct CompetitorTrace { + /// Which source-local competitor this trace belongs to. + pub competitor: CompetitorKey, + /// The source-clock timestamp of the first captured sample, if any. + #[ts(optional)] + pub from: Option, + /// Microseconds between consecutive samples (the capture cadence of the first chunk). + #[ts(type = "number")] + pub period_micros: u32, + /// The concatenated per-tick RSSI samples (filtered ADC counts), oldest first. + pub samples: Vec, + /// The **actual** source-clock timestamp (µs) of each sample, when the trace came from a dense + /// history. RH's marshal history is non-uniformly spaced (bursts of peak/nadir entries around + /// each crossing), so a uniform `from + i·period_micros` grid badly misplaces samples and + /// understates the span; a renderer that has these should plot each sample at its real time. + /// `None` for the coarse streaming path, where `from`/`period_micros` is exact. + #[serde(default)] + #[ts(optional, type = "Array")] + pub times: Option>, + /// The enter detection threshold, where captured. + #[ts(optional)] + pub enter: Option, + /// The exit detection threshold, where captured. + #[ts(optional)] + pub exit: Option, +} + +/// The signal-trace read model for a heat: one [`CompetitorTrace`] per competitor that produced +/// any signal facts, ordered deterministically by [`CompetitorKey`] (marshaling Slice 1). +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct SignalTraceView { + /// Per-competitor traces, ordered by competitor key. + pub competitors: Vec, +} + +impl SignalTraceView { + /// Look up a single competitor's trace by key, if present. + pub fn competitor(&self, key: &CompetitorKey) -> Option<&CompetitorTrace> { + self.competitors.iter().find(|c| &c.competitor == key) + } +} + +/// Fold a sequence of events into the per-heat [`SignalTraceView`] (marshaling Slice 1; dense +/// upgrade — RH `current_marshal_data`). +/// +/// Folds [`Event::SignalChunk`], [`Event::SignalThresholds`], and [`Event::SignalHistory`]. +/// Thresholds are **last-writer-wins** per competitor. The two trace sources are handled with a +/// **prefer-dense** rule: +/// +/// - [`Event::SignalChunk`] — the *coarse* live-streamed samples (one per `node_data` heartbeat). +/// They **append** to the competitor's running buffer in log order, reconstructing the stream +/// exactly. +/// - [`Event::SignalHistory`] — the *dense, full-fidelity* per-tick history RotorHazard records, +/// pulled from the request-driven `current_marshal_data` at heat end. When a competitor has **any** +/// dense history, it **supersedes** the coarse chunk samples entirely: the view carries the dense +/// trace, not the streaming approximation. Multiple histories for one competitor are last-writer- +/// wins (a re-pull replaces the earlier one). With no dense history the coarse chunks stand, so a +/// heat that ended before the pull (or a non-RH source) is unchanged. +/// +/// Because the dense history carries an **explicit per-sample time** (not a uniform cadence), the +/// view's uniform `from`/`period_micros` grid is derived from those times: `from` is the first +/// sample's instant and `period_micros` is the **first inter-sample delta** (RotorHazard samples at +/// a near-fixed rate). The samples themselves are stored verbatim — native integer ADC counts, no +/// resampling (the Slice 1 fidelity caution) — so the rendered grid is faithful to the real sample +/// count even where the cadence drifts slightly. A single-sample history keeps a `period_micros` of +/// `0` (degenerate, but the grid still places it at `from`). +/// +/// Pure and deterministic — no clock, no hidden state — so folding the same events twice yields the +/// identical view (the determinism-on-replay guarantee Slice 1 is built around): the dense/coarse +/// choice is a pure function of which facts are present, independent of fold order. +/// +/// `events` is the heat's window (the server scopes it before folding, exactly as the lap/audit +/// projections do); the fold itself is window-agnostic, so it is equally correct over the full log. +pub fn signal_trace<'a, I>(events: I) -> SignalTraceView +where + I: IntoIterator, +{ + // Per competitor, in first-seen order; sorted by key at the end for a stable view. + struct Acc { + from: Option, + period_micros: u32, + samples: Vec, + enter: Option, + exit: Option, + /// The dense history that supersedes the coarse `samples`/`from`/`period_micros`, if any has + /// been seen (last writer wins). When present it is resolved into the trace at emit time. + dense: Option, + } + impl Acc { + fn empty() -> Self { + Acc { + from: None, + period_micros: 0, + samples: Vec::new(), + enter: None, + exit: None, + dense: None, + } + } + } + let mut by_competitor: BTreeMap = BTreeMap::new(); + + for event in events { + match event { + Event::SignalChunk(chunk) => { + let key = CompetitorKey { + adapter: chunk.adapter.clone(), + competitor: chunk.competitor.clone(), + }; + let acc = by_competitor.entry(key).or_insert_with(Acc::empty); + // The first chunk anchors the time base; later chunks append onto it. + if acc.from.is_none() { + acc.from = Some(chunk.from); + acc.period_micros = chunk.period_micros; + } + acc.samples.extend_from_slice(&chunk.rssi); + } + Event::SignalHistory(history) => { + let key = CompetitorKey { + adapter: history.adapter.clone(), + competitor: history.competitor.clone(), + }; + let acc = by_competitor.entry(key).or_insert_with(Acc::empty); + // Prefer-dense: the dense history supersedes the coarse chunks. Last writer wins, so + // a re-pull replaces the earlier history. An empty history is ignored (it carries no + // evidence, so it must not blank out the coarse trace). + if !history.rssi.is_empty() { + acc.dense = Some(history.clone()); + } + } + Event::SignalThresholds(t) => { + let key = CompetitorKey { + adapter: t.adapter.clone(), + competitor: t.competitor.clone(), + }; + let acc = by_competitor.entry(key).or_insert_with(Acc::empty); + // Last writer wins. + acc.enter = Some(t.enter); + acc.exit = Some(t.exit); + } + _ => {} + } + } + + SignalTraceView { + competitors: by_competitor + .into_iter() + .map(|(competitor, acc)| { + // Prefer-dense: when a dense history is present it replaces the coarse stream. The + // uniform `from`/`period_micros` grid is kept (compat), but the explicit per-sample + // `times` are carried too so a renderer can plot each sample at its real instant — RH's + // history is bursty, so the uniform grid alone badly compresses the trace. + let (from, period_micros, samples, times) = match acc.dense { + Some(history) => { + let (from, period_micros, samples) = dense_trace_grid(&history); + (from, period_micros, samples, Some(history.times)) + } + None => (acc.from, acc.period_micros, acc.samples, None), + }; + CompetitorTrace { + competitor, + from, + period_micros, + samples, + times, + enter: acc.enter, + exit: acc.exit, + } + }) + .collect(), + } +} + +/// Resolve a dense [`SignalHistory`] into the `(from, period_micros, samples)` the uniform-grid +/// [`CompetitorTrace`] carries, preserving the native samples verbatim (no resampling). +/// +/// `from` is the first sample's instant; `period_micros` is the first **positive** inter-sample +/// delta (RH samples at a near-fixed rate, so this anchors the grid the renderer draws on). The +/// `samples` are the dense RSSI vector unchanged. A dense history can legitimately repeat a timestamp +/// — e.g. a peak reported at the same first/last time, so `history_times` reads `[t, t, …]` — so the +/// grid skips zero/negative deltas to avoid a degenerate period of `0`; it falls back to `0` only +/// when every delta is non-positive (a single distinct time, including a single-sample history). +fn dense_trace_grid(history: &SignalHistory) -> (Option, u32, Vec) { + let from = history.times.first().copied().map(SourceTime::from_micros); + let period_micros = history + .times + .windows(2) + .map(|w| w[1] - w[0]) + .find(|&d| d > 0) + .unwrap_or(0) + .clamp(0, u32::MAX as i64) as u32; + (from, period_micros, history.rssi.clone()) +} + #[cfg(test)] mod tests { use super::*; @@ -405,6 +1194,7 @@ mod tests { sequence, gate: GateIndex::LAP, signal: None, + heat: None, }) } @@ -417,6 +1207,7 @@ mod tests { sequence: None, gate: GateIndex(gate), signal: Some(SignalContext { rssi_peak: None }), + heat: None, }) } @@ -427,6 +1218,19 @@ mod tests { } } + /// Expected `(lap number, duration)` pair — the marshaling-irrelevant lap shape these + /// fold tests assert. Laps now also carry `start_ref`/`end_ref` global offsets (#55); + /// those are exercised by dedicated offset-targeting tests, so the duration folds compare + /// on `(number, duration)` via [`bare`] to stay focused on the fold arithmetic. + fn ld(number: u32, duration_micros: i64) -> (u32, i64) { + (number, duration_micros) + } + + /// Project laps to their `(number, duration)` pairs, dropping the ref offsets. + fn bare(laps: &[Lap]) -> Vec<(u32, i64)> { + laps.iter().map(|l| (l.number, l.duration_micros)).collect() + } + #[test] fn clean_multi_lap_run_yields_k_minus_one_laps() { // 4 lap-gate passes ⇒ 3 laps with exact integer-microsecond durations. @@ -439,21 +1243,8 @@ mod tests { let result = lap_list(&events); let laps = &result.competitor(&key("vd", "A")).unwrap().laps; assert_eq!( - laps, - &vec![ - Lap { - number: 1, - duration_micros: 3_000_000, - }, - Lap { - number: 2, - duration_micros: 2_500_000, - }, - Lap { - number: 3, - duration_micros: 4_500_000, - }, - ] + bare(laps), + vec![ld(1, 3_000_000), ld(2, 2_500_000), ld(3, 4_500_000),] ); let cl = result.competitor(&key("vd", "A")).unwrap(); assert_eq!(cl.lap_count(), 3); @@ -466,7 +1257,7 @@ mod tests { let events = vec![pass("vd", "A", 1_000_000, Some(1))]; let result = lap_list(&events); let cl = result.competitor(&key("vd", "A")).unwrap(); - assert_eq!(cl.laps, vec![]); + assert_eq!(bare(&cl.laps), vec![]); assert_eq!(cl.lap_count(), 0); assert_eq!(cl.total_micros(), 0); assert_eq!(cl.best(), None); @@ -491,28 +1282,10 @@ mod tests { let result = lap_list(&events); let a = result.competitor(&key("vd", "A")).unwrap(); - assert_eq!( - a.laps, - vec![ - Lap { - number: 1, - duration_micros: 3_000_000, - }, - Lap { - number: 2, - duration_micros: 2_000_000, - }, - ] - ); + assert_eq!(bare(&a.laps), vec![ld(1, 3_000_000), ld(2, 2_000_000),]); let b = result.competitor(&key("vd", "B")).unwrap(); - assert_eq!( - b.laps, - vec![Lap { - number: 1, - duration_micros: 4_000_000, - }] - ); + assert_eq!(bare(&b.laps), vec![ld(1, 4_000_000)]); } #[test] @@ -527,18 +1300,12 @@ mod tests { let result = lap_list(&events); assert_eq!(result.competitors.len(), 2); assert_eq!( - result.competitor(&key("rh-a", "node-2")).unwrap().laps, - vec![Lap { - number: 1, - duration_micros: 2_000_000, - }] + bare(&result.competitor(&key("rh-a", "node-2")).unwrap().laps), + vec![ld(1, 2_000_000)] ); assert_eq!( - result.competitor(&key("rh-b", "node-2")).unwrap().laps, - vec![Lap { - number: 1, - duration_micros: 3_000_000, - }] + bare(&result.competitor(&key("rh-b", "node-2")).unwrap().laps), + vec![ld(1, 3_000_000)] ); } @@ -553,11 +1320,8 @@ mod tests { ]; let result = lap_list(&events); assert_eq!( - result.competitor(&key("vd", "A")).unwrap().laps, - vec![Lap { - number: 1, - duration_micros: 4_000_000, - }] + bare(&result.competitor(&key("vd", "A")).unwrap().laps), + vec![ld(1, 4_000_000)] ); } @@ -584,11 +1348,8 @@ mod tests { ]; let result = lap_list(&events); assert_eq!( - result.competitor(&key("vd", "A")).unwrap().laps, - vec![Lap { - number: 1, - duration_micros: 2_000_000, - }] + bare(&result.competitor(&key("vd", "A")).unwrap().laps), + vec![ld(1, 2_000_000)] ); } @@ -603,17 +1364,8 @@ mod tests { ]; let result = lap_list(&events); assert_eq!( - result.competitor(&key("vd", "A")).unwrap().laps, - vec![ - Lap { - number: 1, - duration_micros: 3_000_000, // 4.0s - 1.0s - }, - Lap { - number: 2, - duration_micros: 2_000_000, // 6.0s - 4.0s - }, - ] + bare(&result.competitor(&key("vd", "A")).unwrap().laps), + vec![ld(1, 3_000_000), ld(2, 2_000_000),] ); } @@ -627,17 +1379,67 @@ mod tests { ]; let result = lap_list(&events); assert_eq!( - result.competitor(&key("vd", "A")).unwrap().laps, - vec![ - Lap { - number: 1, - duration_micros: 3_000_000, // 5.0s - 2.0s - }, - Lap { - number: 2, - duration_micros: 2_000_000, // 7.0s - 5.0s - }, - ] + bare(&result.competitor(&key("vd", "A")).unwrap().laps), + vec![ld(1, 3_000_000), ld(2, 2_000_000),] + ); + } + + #[test] + fn registrations_map_bindings_last_writer_wins() { + use gridfpv_events::PilotId; + let events = vec![ + Event::CompetitorRegistered { + adapter: AdapterId("rh".into()), + competitor: CompetitorRef("node-2".into()), + pilot: PilotId("acroace".into()), + }, + // A different competitor binds to a different pilot. + Event::CompetitorRegistered { + adapter: AdapterId("rh".into()), + competitor: CompetitorRef("node-3".into()), + pilot: PilotId("bee".into()), + }, + // node-2 is re-bound: the later registration wins. + Event::CompetitorRegistered { + adapter: AdapterId("rh".into()), + competitor: CompetitorRef("node-2".into()), + pilot: PilotId("zoomer".into()), + }, + ]; + let map = registrations(&events); + assert_eq!( + map.get(&key("rh", "node-2")), + Some(&PilotId("zoomer".into())) + ); + assert_eq!(map.get(&key("rh", "node-3")), Some(&PilotId("bee".into()))); + // An unregistered competitor is simply absent. + assert_eq!(map.get(&key("rh", "node-9")), None); + } + + #[test] + fn registrations_are_per_source() { + use gridfpv_events::PilotId; + // The same ref on two adapters is two distinct bindings. + let events = vec![ + Event::CompetitorRegistered { + adapter: AdapterId("rh-a".into()), + competitor: CompetitorRef("node-2".into()), + pilot: PilotId("acroace".into()), + }, + Event::CompetitorRegistered { + adapter: AdapterId("rh-b".into()), + competitor: CompetitorRef("node-2".into()), + pilot: PilotId("bee".into()), + }, + ]; + let map = registrations(&events); + assert_eq!( + map.get(&key("rh-a", "node-2")), + Some(&PilotId("acroace".into())) + ); + assert_eq!( + map.get(&key("rh-b", "node-2")), + Some(&PilotId("bee".into())) ); } @@ -670,6 +1472,7 @@ mod marshaling_tests { sequence, gate: GateIndex::LAP, signal: None, + heat: None, }) } @@ -684,6 +1487,7 @@ mod marshaling_tests { adapter: AdapterId(adapter.into()), competitor: CompetitorRef(competitor.into()), at: SourceTime::from_micros(at), + heat: None, } } @@ -694,6 +1498,13 @@ mod marshaling_tests { } } + fn split(target: u64, at: i64) -> Event { + Event::LapSplit { + target: LogRef(target), + at: SourceTime::from_micros(at), + } + } + fn key(adapter: &str, competitor: &str) -> CompetitorKey { CompetitorKey { adapter: AdapterId(adapter.into()), @@ -711,7 +1522,28 @@ mod marshaling_tests { .collect() } - fn laps_of(list: &LapList, adapter: &str, competitor: &str) -> Vec { + /// Expected `(lap number, duration)` pair. Laps also carry `start_ref`/`end_ref` offsets + /// (#55); these fold goldens assert the duration arithmetic via [`laps_of`], and the + /// offset-targeting behaviour is checked by the dedicated `*_ref*` tests below. + fn ld(number: u32, duration_micros: i64) -> (u32, i64) { + (number, duration_micros) + } + + /// A competitor's laps as `(number, duration)` pairs, dropping the ref offsets — the fold + /// goldens compare on lap arithmetic only. + fn laps_of(list: &LapList, adapter: &str, competitor: &str) -> Vec<(u32, i64)> { + list.competitor(&key(adapter, competitor)) + .map(|c| { + c.laps + .iter() + .map(|l| (l.number, l.duration_micros)) + .collect() + }) + .unwrap_or_default() + } + + /// A competitor's raw laps (with refs intact), for the offset-targeting tests. + fn raw_laps_of(list: &LapList, adapter: &str, competitor: &str) -> Vec { list.competitor(&key(adapter, competitor)) .map(|c| c.laps.clone()) .unwrap_or_default() @@ -739,13 +1571,7 @@ mod marshaling_tests { voided(1), // offset 3 — voids the phantom ]; let result = lap_list_marshaled(tagged(&events)); - assert_eq!( - laps_of(&result, "vd", "A"), - vec![Lap { - number: 1, - duration_micros: 5_000_000, // 6.0s - 1.0s, the 4.0s pass is gone - }] - ); + assert_eq!(laps_of(&result, "vd", "A"), vec![ld(1, 5_000_000)]); } #[test] @@ -760,16 +1586,7 @@ mod marshaling_tests { let result = lap_list_marshaled(tagged(&events)); assert_eq!( laps_of(&result, "vd", "A"), - vec![ - Lap { - number: 1, - duration_micros: 3_000_000, // 4.0s - 1.0s - }, - Lap { - number: 2, - duration_micros: 3_000_000, // 7.0s - 4.0s - }, - ] + vec![ld(1, 3_000_000), ld(2, 3_000_000),] ); } @@ -785,16 +1602,7 @@ mod marshaling_tests { let result = lap_list_marshaled(tagged(&events)); assert_eq!( laps_of(&result, "vd", "A"), - vec![ - Lap { - number: 1, - duration_micros: 3_000_000, // 4.0s - 1.0s (was 4.0s before adjust) - }, - Lap { - number: 2, - duration_micros: 3_000_000, // 7.0s - 4.0s (was 2.0s before adjust) - }, - ] + vec![ld(1, 3_000_000), ld(2, 3_000_000),] ); } @@ -810,13 +1618,7 @@ mod marshaling_tests { voided(1), // offset 4 — ...then void (wins) ]; let result = lap_list_marshaled(tagged(&events)); - assert_eq!( - laps_of(&result, "vd", "A"), - vec![Lap { - number: 1, - duration_micros: 7_000_000, // 8.0s - 1.0s, offset 1 voided - }] - ); + assert_eq!(laps_of(&result, "vd", "A"), vec![ld(1, 7_000_000)]); } #[test] @@ -834,16 +1636,273 @@ mod marshaling_tests { let result = lap_list_marshaled(tagged(&events)); assert_eq!( laps_of(&result, "vd", "A"), - vec![ - Lap { - number: 1, - duration_micros: 3_000_000, // 4.0s - 1.0s — pass 1 is back - }, - Lap { - number: 2, - duration_micros: 2_000_000, // 6.0s - 4.0s - }, - ] + vec![ld(1, 3_000_000), ld(2, 2_000_000),] + ); + } + + #[test] + fn a_voided_pass_is_recorded_on_the_lap_list_and_unvoiding_clears_it() { + // The removal record travels WITH the lap list (the void/re-detection shared-data + // rule): an RD-voided crossing shows up in `CompetitorLaps::voided` at its recorded + // instant with its own offset — so the console can render it struck-through and the + // threshold re-detection can refuse to re-propose it. Un-voiding (void the void) + // returns the pass to the laps and clears the record. + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // offset 0 + pass("vd", "A", 4_000_000, Some(2)), // offset 1 — the not-a-full-lap crossing + pass("vd", "A", 6_000_000, Some(3)), // offset 2 + voided(1), // offset 3 — the RD removes it + ]; + let result = lap_list_marshaled(tagged(&events)); + let cl = result + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "A") + .unwrap(); + // One surviving lap (0 → 2); the voided crossing is recorded, not forgotten. + assert_eq!(cl.laps.len(), 1); + assert_eq!( + cl.voided, + vec![VoidedPass { + at: SourceTime::from_micros(4_000_000), + pass_ref: LogRef(1), + void_ref: LogRef(3), + reason: VoidReason::Marshal, + }] + ); + + // Void the void: the pass returns to the laps and leaves the removal record. + let mut events = events; + events.push(voided(3)); // offset 4 + let result = lap_list_marshaled(tagged(&events)); + let cl = result + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "A") + .unwrap(); + assert_eq!(cl.laps.len(), 2); + assert!(cl.voided.is_empty()); + } + + #[test] + fn min_lap_floor_suppresses_the_phantom_double_detection() { + // The live bug (Audit Shakedown): every pilot got TWO passes 4ms apart at race start — + // the second closed a phantom 0.004s "lap 1" and shifted every real lap's number. + // Under a 5s floor the echo drops to the removal record; the chain reads holeshot → + // real laps, exactly as if the timer had never double-fired. + let events = vec![ + pass("vd", "A", 651_000, Some(1)), // offset 0 — holeshot (kept: first) + pass("vd", "A", 655_000, Some(2)), // offset 1 — the 4ms echo (suppressed) + pass("vd", "A", 7_208_000, Some(3)), // offset 2 — real lap 1 + pass("vd", "A", 13_500_000, Some(4)), // offset 3 — real lap 2 + ]; + let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000)); + let cl = result + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "A") + .unwrap(); + let durations: Vec = cl.laps.iter().map(|l| l.duration_micros).collect(); + assert_eq!( + durations, + vec![6_557_000, 6_292_000], + "holeshot opens the chain; the echo never closes a lap" + ); + assert_eq!( + cl.voided, + vec![VoidedPass { + at: SourceTime::from_micros(655_000), + pass_ref: LogRef(1), + void_ref: LogRef(1), // restore target = the pass itself (a marshal re-time) + reason: VoidReason::UnderMinLap, + }] + ); + // No floor ⇒ bit-identical to the plain fold (rounds predating the setting). + let unfloored = lap_list_marshaled_with_floor(tagged(&events), None); + let plain = lap_list_marshaled(tagged(&events)); + assert_eq!(unfloored, plain); + assert_eq!(plain.competitors[0].laps.len(), 3); + } + + #[test] + fn a_marshal_re_time_exempts_a_pass_from_the_floor() { + // The RESTORE path: the floor suppressed a pass the marshal believes is real. An + // AdjustLap re-asserting its raw instant is an explicit ruling — it outranks the + // floor and the pass returns to the chain (whiff of a whoop track's 2s laps). + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // offset 0 + pass("vd", "A", 3_000_000, Some(2)), // offset 1 — 2s lap, under a 5s floor + pass("vd", "A", 9_000_000, Some(3)), // offset 2 + adjusted(1, 3_000_000), // offset 3 — marshal: "that 2s lap is real" + ]; + let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000)); + let cl = result + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "A") + .unwrap(); + assert_eq!( + cl.laps + .iter() + .map(|l| l.duration_micros) + .collect::>(), + vec![2_000_000, 6_000_000], + "the blessed pass closes its lap despite the floor" + ); + assert!(cl.voided.is_empty()); + } + + #[test] + fn marshal_created_passes_are_never_floor_suppressed() { + // An inserted pass is a ruling by construction — even one that closes a short lap + // stands (the marshal typed the time; the floor guards raw detections only). + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // offset 0 + pass("vd", "A", 10_000_000, Some(2)), // offset 1 + inserted("vd", "A", 2_500_000), // offset 2 — a 1.5s lap, by ruling + ]; + let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000)); + let cl = result + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "A") + .unwrap(); + assert_eq!(cl.laps.len(), 2, "the inserted pass closes its lap"); + assert!(cl.voided.is_empty()); + } + + #[test] + fn a_burst_of_rapid_echoes_all_suppress_against_the_last_kept_pass() { + // Three reflections inside the floor window: each compares against the last KEPT + // pass, so the whole burst drops — not every-other one. + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // kept (first) + pass("vd", "A", 1_004_000, Some(2)), // echo — suppressed + pass("vd", "A", 1_009_000, Some(3)), // echo — suppressed + pass("vd", "A", 1_030_000, Some(4)), // echo — suppressed + pass("vd", "A", 8_000_000, Some(5)), // real — kept (7s from last kept) + ]; + let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000)); + let cl = result + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "A") + .unwrap(); + assert_eq!(cl.laps.len(), 1); + assert_eq!(cl.laps[0].duration_micros, 7_000_000); + assert_eq!(cl.voided.len(), 3); + assert!( + cl.voided + .iter() + .all(|v| v.reason == VoidReason::UnderMinLap) + ); + } + + #[test] + fn floor_suppression_composes_with_marshal_voids() { + // A marshal void recomputes the chain BEFORE the floor: voiding the first pass makes + // the echo the new chain opener (kept — nothing precedes it), and both removal + // reasons render side by side. + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // offset 0 — marshal-voided below + pass("vd", "A", 1_004_000, Some(2)), // offset 1 — becomes the opener + pass("vd", "A", 8_000_000, Some(3)), // offset 2 — real lap + voided(0), // offset 3 + ]; + let result = lap_list_marshaled_with_floor(tagged(&events), Some(5_000_000)); + let cl = result + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "A") + .unwrap(); + assert_eq!(cl.laps.len(), 1, "opener (echo) -> real pass = one lap"); + assert_eq!(cl.voided.len(), 1); + assert_eq!(cl.voided[0].reason, VoidReason::Marshal); + } + + #[test] + fn a_depth_three_void_chain_re_voids_the_base_pass() { + // void(void(void(P))) — the RD removed, restored, and re-removed: last writer wins, + // so P is voided again and back on the removal record. (The old two-level special + // case silently no-opped here, leaving P alive against the newest ruling.) + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // offset 0 + pass("vd", "A", 4_000_000, Some(2)), // offset 1 + pass("vd", "A", 6_000_000, Some(3)), // offset 2 + voided(1), // offset 3 — remove + voided(3), // offset 4 — restore + voided(4), // offset 5 — remove again + ]; + let result = lap_list_marshaled(tagged(&events)); + let cl = result + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "A") + .unwrap(); + assert_eq!(cl.laps.len(), 1, "0 -> 2 is the one surviving lap"); + assert_eq!( + cl.voided, + vec![VoidedPass { + at: SourceTime::from_micros(4_000_000), + pass_ref: LogRef(1), + void_ref: LogRef(5), + reason: VoidReason::Marshal, + }] + ); + } + + #[test] + fn a_retimed_then_voided_pass_records_its_raw_instant() { + // The removal record exists so RE-DETECTION recognises the crossing on the trace — + // and the trace knows nothing of a re-time. Adjust 4.0s -> 14.0s, then void: the + // record must say 4.0s (where the crossing physically is), not 14.0s. + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // offset 0 + pass("vd", "A", 4_000_000, Some(2)), // offset 1 + adjusted(1, 14_000_000), // offset 2 + voided(1), // offset 3 + ]; + let result = lap_list_marshaled(tagged(&events)); + let cl = result + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "A") + .unwrap(); + assert_eq!( + cl.voided, + vec![VoidedPass { + at: SourceTime::from_micros(4_000_000), + pass_ref: LogRef(1), + void_ref: LogRef(3), + reason: VoidReason::Marshal, + }] + ); + } + + #[test] + fn splitting_a_split_synthetic_pass_works_recursively() { + // One 12s lap missed TWO crossings: split at 4s, then split the still-too-long + // second half (the lap ending at the synthetic pass? no — ending at the raw pass) + // by targeting the SYNTHETIC pass's own lap. The second split targets the first + // split's offset and must resolve to a real source recursively (it used to vanish + // silently while the audit showed it landed). + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // offset 0 — opens + pass("vd", "A", 13_000_000, Some(2)), // offset 1 — one 12s lap + split(1, 5_000_000), // offset 2 — synthetic at 5s + split(2, 9_000_000), // offset 3 — split the SYNTHETIC's chain + ]; + let result = lap_list_marshaled(tagged(&events)); + let cl = result + .competitors + .iter() + .find(|c| c.competitor.competitor.0 == "A") + .unwrap(); + let durations: Vec = cl.laps.iter().map(|l| l.duration_micros).collect(); + assert_eq!( + durations, + vec![4_000_000, 4_000_000, 4_000_000], + "three real laps: 1-5, 5-9, 9-13" ); } @@ -858,13 +1917,7 @@ mod marshaling_tests { voided(2), // offset 3 — void the insert ]; let result = lap_list_marshaled(tagged(&events)); - assert_eq!( - laps_of(&result, "vd", "A"), - vec![Lap { - number: 1, - duration_micros: 6_000_000, // 7.0s - 1.0s, the insert is gone - }] - ); + assert_eq!(laps_of(&result, "vd", "A"), vec![ld(1, 6_000_000)]); } #[test] @@ -878,13 +1931,7 @@ mod marshaling_tests { voided(2), // offset 3 — ...cancel the re-time ]; let result = lap_list_marshaled(tagged(&events)); - assert_eq!( - laps_of(&result, "vd", "A"), - vec![Lap { - number: 1, - duration_micros: 4_000_000, // 5.0s - 1.0s, reverted from the 4.0s adjust - }] - ); + assert_eq!(laps_of(&result, "vd", "A"), vec![ld(1, 4_000_000)]); } #[test] @@ -906,10 +1953,7 @@ mod marshaling_tests { assert_eq!(lap_list_marshaled(tagged(&events)), lap_list(&events)); assert_eq!( laps_of(&lap_list_marshaled(tagged(&events)), "vd", "A"), - vec![Lap { - number: 1, - duration_micros: 3_000_000, - }] + vec![ld(1, 3_000_000)] ); } @@ -950,13 +1994,7 @@ mod marshaling_tests { // Fold — and use the result so the corrected view genuinely differs. let result = lap_list_marshaled(tagged(&events)); - assert_eq!( - laps_of(&result, "vd", "A"), - vec![Lap { - number: 1, - duration_micros: 3_000_000, // 4.0s - 1.0s (adjusted), offset 2 voided - }] - ); + assert_eq!(laps_of(&result, "vd", "A"), vec![ld(1, 3_000_000)]); let after: Vec = events .iter() @@ -969,6 +2007,118 @@ mod marshaling_tests { ); } + // --- LapSplit (Slice 2) ---------------------------------------------------- + + #[test] + fn lap_split_makes_two_laps_from_one() { + // One over-long lap (1.0s → 7.0s) ending at the offset-1 pass; the timer missed a + // mid-lap detection. Splitting it at 4.0s — attributed to the target's competitor — + // turns the single 6.0s lap into two 3.0s laps. + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // offset 0 + pass("vd", "A", 7_000_000, Some(2)), // offset 1 — ends the over-long lap + split(1, 4_000_000), // offset 2 — split at 4.0s + ]; + let result = lap_list_marshaled(tagged(&events)); + assert_eq!( + laps_of(&result, "vd", "A"), + vec![ld(1, 3_000_000), ld(2, 3_000_000),] + ); + } + + #[test] + fn lap_split_attributes_to_the_target_competitor_only() { + // Two competitors interleaved; splitting B's lap must add a pass for B, never A. + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // offset 0 + pass("vd", "B", 2_000_000, Some(1)), // offset 1 + pass("vd", "A", 4_000_000, Some(2)), // offset 2 + pass("vd", "B", 8_000_000, Some(2)), // offset 3 — ends B's over-long lap + split(3, 5_000_000), // offset 4 — split B's lap at 5.0s + ]; + let result = lap_list_marshaled(tagged(&events)); + // A is untouched: one 3.0s lap. + assert_eq!(laps_of(&result, "vd", "A"), vec![ld(1, 3_000_000)]); + // B's single 6.0s lap becomes two (2.0→5.0, 5.0→8.0). + assert_eq!( + laps_of(&result, "vd", "B"), + vec![ld(1, 3_000_000), ld(2, 3_000_000),] + ); + } + + #[test] + fn void_the_split_removes_the_synthetic_pass() { + // The split's synthetic pass is addressable by the split's own offset, so a later + // void of that offset removes it — the single over-long lap is restored. + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // offset 0 + pass("vd", "A", 7_000_000, Some(2)), // offset 1 + split(1, 4_000_000), // offset 2 — synthetic mid-lap pass + voided(2), // offset 3 — void the split + ]; + let result = lap_list_marshaled(tagged(&events)); + assert_eq!(laps_of(&result, "vd", "A"), vec![ld(1, 6_000_000)]); + } + + #[test] + fn void_the_void_of_a_split_restores_it() { + // "Void the void" works on a split: void the split (offset 3), then void *that* + // void (offset 4) — the synthetic pass comes back and the lap is two again. + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // offset 0 + pass("vd", "A", 7_000_000, Some(2)), // offset 1 + split(1, 4_000_000), // offset 2 — synthetic + voided(2), // offset 3 — void the split + voided(3), // offset 4 — void the void + ]; + let result = lap_list_marshaled(tagged(&events)); + assert_eq!( + laps_of(&result, "vd", "A"), + vec![ld(1, 3_000_000), ld(2, 3_000_000),] + ); + } + + #[test] + fn folding_the_same_events_twice_is_identical_with_a_split() { + // Determinism-on-replay (mirrors `fold_is_idempotent_recompute_equivalence`): a log + // mixing a split with the other rulings folds to the identical result twice over. + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), + pass("vd", "A", 7_000_000, Some(2)), + pass("vd", "A", 10_000_000, Some(3)), + split(1, 4_000_000), + adjusted(2, 9_500_000), + inserted("vd", "A", 12_000_000), + ]; + let first = lap_list_marshaled(tagged(&events)); + let second = lap_list_marshaled(tagged(&events)); + assert_eq!(first, second); + } + + #[test] + fn edit_time_shifts_both_neighbour_lap_durations() { + // Slice-2 "edit-time": re-timing a *middle* pass shifts BOTH adjacent lap durations + // that share it — no new event, the duration recompute is structural in + // `corrected_passes`. Verify the prior-lap and next-lap durations both change. + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // offset 0 + pass("vd", "A", 5_000_000, Some(2)), // offset 1 — the shared middle pass + pass("vd", "A", 9_000_000, Some(3)), // offset 2 + ]; + // Before the edit: laps are 4.0s (1→5) and 4.0s (5→9). + let before = laps_of(&lap_list_marshaled(tagged(&events)), "vd", "A"); + assert_eq!(before[0].1, 4_000_000); + assert_eq!(before[1].1, 4_000_000); + + // Re-time the middle pass from 5.0s to 6.0s — the prior lap lengthens and the next + // lap shortens by the same 1.0s, both neighbours moving off one edit. + let mut edited = events.clone(); + edited.push(adjusted(1, 6_000_000)); // offset 3 + let after = laps_of(&lap_list_marshaled(tagged(&edited)), "vd", "A"); + assert_eq!(after[0].1, 5_000_000, "prior lap lengthened 4→5s"); + assert_eq!(after[1].1, 3_000_000, "next lap shortened 4→3s"); + } + #[test] fn adjust_targeting_a_synthetic_inserted_pass_retimes_it() { // An inserted lap is itself addressable; a later adjust re-times it. @@ -981,16 +2131,554 @@ mod marshaling_tests { let result = lap_list_marshaled(tagged(&events)); assert_eq!( laps_of(&result, "vd", "A"), + vec![ld(1, 4_000_000), ld(2, 4_000_000),] + ); + } + + // --- Lap end_ref/start_ref: the load-bearing UI-targeting offsets (#55) ---------- + + #[test] + fn lap_refs_carry_the_global_pass_offsets() { + // Each lap's start_ref/end_ref are the GLOBAL append offsets of its bounding passes — + // the stable command target a UI selects. 3 passes at offsets 0,1,2 ⇒ laps + // (start=0,end=1) and (start=1,end=2). + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), // offset 0 + pass("vd", "A", 4_000_000, Some(2)), // offset 1 + pass("vd", "A", 6_000_000, Some(3)), // offset 2 + ]; + let result = lap_list_marshaled(tagged(&events)); + let laps = raw_laps_of(&result, "vd", "A"); + assert_eq!(laps[0].start_ref, LogRef(0)); + assert_eq!(laps[0].end_ref, LogRef(1)); + assert_eq!(laps[1].start_ref, LogRef(1)); + assert_eq!(laps[1].end_ref, LogRef(2)); + } + + #[test] + fn lap_refs_are_global_offsets_not_window_relative() { + // THE BUG THIS FIXES: when the fold is fed real global offsets (a heat starting at + // offset 100, e.g. the heat-window path), the lap refs are those global offsets — NOT + // re-enumerated 0,1,2. A UI selecting lap 2's end targets global offset 102, and a void + // of that offset removes the right pass. + let events = vec![ + pass("vd", "A", 1_000_000, Some(1)), + pass("vd", "A", 4_000_000, Some(2)), + pass("vd", "A", 6_000_000, Some(3)), + ]; + // Feed the fold global offsets 100,101,102 (as the heat window now does). + let tagged_global: Vec<(u64, &Event)> = events + .iter() + .enumerate() + .map(|(i, e)| (100 + i as u64, e)) + .collect(); + let result = lap_list_marshaled(tagged_global); + let laps = raw_laps_of(&result, "vd", "A"); + assert_eq!( + laps[1].end_ref, + LogRef(102), + "must be the global offset, not 2" + ); + + // And that ref is a valid void target: void lap 2's end pass (offset 102) and the lap is + // gone — proving the UI-selected ref hits the RIGHT pass. + let end_ref = laps[1].end_ref; + let mut log: Vec = events.clone(); + // Append the void at its own real global offset; re-fold the same global window. + let mut full: Vec<(u64, Event)> = log + .drain(..) + .enumerate() + .map(|(i, e)| (100 + i as u64, e)) + .collect(); + full.push((103, Event::DetectionVoided { target: end_ref })); + let refolded = lap_list_marshaled(full.iter().map(|(o, e)| (*o, e))); + // Voiding the offset-102 pass leaves laps (100→101) only. + assert_eq!(laps_of(&refolded, "vd", "A"), vec![ld(1, 3_000_000)]); + } + + #[test] + fn selecting_a_lap_to_split_targets_the_right_pass() { + // A UI selects an over-long lap and splits it: the split must target that lap's END pass. + // Two competitors interleaved with non-zero global offsets so a window-relative bug would + // target the wrong pass. + let events = [ + pass("vd", "A", 1_000_000, Some(1)), // global 50 + pass("vd", "B", 2_000_000, Some(1)), // global 51 + pass("vd", "A", 4_000_000, Some(2)), // global 52 + pass("vd", "B", 8_000_000, Some(2)), // global 53 — ends B's over-long lap + ]; + let tagged_global: Vec<(u64, &Event)> = events + .iter() + .enumerate() + .map(|(i, e)| (50 + i as u64, e)) + .collect(); + let list = lap_list_marshaled(tagged_global); + // The UI selects B's only lap and reads its end_ref to target. + let b_lap = raw_laps_of(&list, "vd", "B")[0].clone(); + assert_eq!(b_lap.end_ref, LogRef(53)); + + // Split that lap at 5.0s, targeting end_ref — B's lap becomes two, A untouched. + let mut full: Vec<(u64, Event)> = events + .iter() + .enumerate() + .map(|(i, e)| (50 + i as u64, e.clone())) + .collect(); + full.push(( + 54, + Event::LapSplit { + target: b_lap.end_ref, + at: SourceTime::from_micros(5_000_000), + }, + )); + let refolded = lap_list_marshaled(full.iter().map(|(o, e)| (*o, e))); + assert_eq!(laps_of(&refolded, "vd", "A"), vec![ld(1, 3_000_000)]); + assert_eq!( + laps_of(&refolded, "vd", "B"), + vec![ld(1, 3_000_000), ld(2, 3_000_000)] + ); + } + + // --- marshaling_log: the audit projection (#55) --------------------------------- + + fn audit(events: &[(Option, Event)], heat: &str) -> Vec { + let heat = HeatId(heat.into()); + let tagged: Vec<(Option, u64, &Event)> = events + .iter() + .enumerate() + .map(|(i, (at, e))| (*at, i as u64, e)) + .collect(); + marshaling_log(tagged, &heat) + } + + #[test] + fn marshaling_log_lists_rulings_newest_first_no_passes() { + let heat = "q-1"; + let log = vec![ + (Some(10), pass("vd", "A", 1_000_000, Some(1))), // offset 0 — automatic, excluded + (Some(20), pass("vd", "A", 4_000_000, Some(2))), // offset 1 — automatic, excluded + (Some(30), Event::DetectionVoided { target: LogRef(1) }), // offset 2 + ( + Some(40), + Event::LapSplit { + target: LogRef(1), + at: SourceTime::from_micros(2_500_000), + }, + ), // offset 3 + ]; + let entries = audit(&log, heat); + // Only the two rulings appear, newest (split, offset 3) first. + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].kind, AuditKind::Split); + assert_eq!(entries[0].at_ref, LogRef(3)); + assert_eq!(entries[0].at, Some(40)); + assert_eq!(entries[1].kind, AuditKind::Voided); + assert_eq!(entries[1].at_ref, LogRef(2)); + } + + #[test] + fn marshaling_log_covers_every_ruling_kind() { + use gridfpv_events::Penalty; + let heat = "q-1"; + let log = vec![ + ( + Some(1), + Event::LapInserted { + adapter: AdapterId("vd".into()), + competitor: CompetitorRef("A".into()), + at: SourceTime::from_micros(3_000_000), + heat: None, + }, + ), + ( + Some(2), + Event::LapAdjusted { + target: LogRef(0), + at: SourceTime::from_micros(4_000_000), + }, + ), + ( + Some(3), + Event::PenaltyApplied { + heat: HeatId(heat.into()), + competitor: CompetitorRef("B".into()), + penalty: Penalty::Disqualify { reason: None }, + }, + ), + (Some(4), Event::RulingReversed { target: LogRef(2) }), + ( + Some(5), + Event::HeatVoided { + heat: HeatId(heat.into()), + }, + ), + ]; + let kinds: Vec = audit(&log, heat).into_iter().map(|e| e.kind).collect(); + // Newest first. + assert_eq!( + kinds, vec![ - Lap { - number: 1, - duration_micros: 4_000_000, // 5.0s - 1.0s + AuditKind::HeatVoided, + AuditKind::RulingReversed, + AuditKind::PenaltyApplied, + AuditKind::Adjusted, + AuditKind::Inserted, + ] + ); + } + + #[test] + fn marshaling_log_covers_slice6_ruling_kinds() { + use gridfpv_events::{Penalty, ProtestOutcome}; + let heat = "q-1"; + let log = vec![ + (Some(1), Event::LapThrownOut { target: LogRef(0) }), + ( + Some(2), + Event::PenaltyApplied { + heat: HeatId(heat.into()), + competitor: CompetitorRef("A".into()), + penalty: Penalty::PointsDeducted { points: 5 }, + }, + ), + ( + Some(3), + Event::ProtestFiled { + heat: HeatId(heat.into()), + competitor: CompetitorRef("B".into()), + note: "cut the course".into(), }, - Lap { - number: 2, - duration_micros: 4_000_000, // 9.0s - 5.0s + ), + ( + Some(4), + Event::ProtestResolved { + target: LogRef(2), + outcome: ProtestOutcome::Upheld, }, + ), + ]; + let entries = audit(&log, heat); + // Newest first. + let kinds: Vec = entries.iter().map(|e| e.kind).collect(); + assert_eq!( + kinds, + vec![ + AuditKind::ProtestResolved, + AuditKind::ProtestFiled, + AuditKind::PenaltyApplied, + AuditKind::LapThrownOut, ] ); + // The summaries read cleanly: the points penalty, the protest text, the outcome. + assert!( + entries + .iter() + .any(|e| e.kind == AuditKind::PenaltyApplied && e.summary.contains("-5 points")) + ); + assert!( + entries + .iter() + .any(|e| e.kind == AuditKind::ProtestFiled && e.summary.contains("cut the course")) + ); + assert!( + entries + .iter() + .any(|e| e.kind == AuditKind::ProtestResolved && e.summary.contains("upheld")) + ); + // Competitor-addressed actions carry the STRUCTURED ref (for client callsign resolution) and + // keep it out of the summary string; the client composes the resolved name into the line. + let penalty = entries + .iter() + .find(|e| e.kind == AuditKind::PenaltyApplied) + .unwrap(); + assert_eq!(penalty.competitor, Some(CompetitorRef("A".into()))); + let protest = entries + .iter() + .find(|e| e.kind == AuditKind::ProtestFiled) + .unwrap(); + assert_eq!(protest.competitor, Some(CompetitorRef("B".into()))); + assert!(!protest.summary.contains('B')); + // Lap-/heat-addressed actions name no competitor. + let resolved = entries + .iter() + .find(|e| e.kind == AuditKind::ProtestResolved) + .unwrap(); + assert_eq!(resolved.competitor, None); + } + + #[test] + fn marshaling_log_excludes_other_heats_penalties_and_voids() { + use gridfpv_events::Penalty; + let heat = "q-1"; + let log = vec![ + ( + Some(1), + Event::PenaltyApplied { + heat: HeatId("q-2".into()), // a DIFFERENT heat — excluded + competitor: CompetitorRef("B".into()), + penalty: Penalty::Disqualify { reason: None }, + }, + ), + ( + Some(2), + Event::HeatVoided { + heat: HeatId("q-2".into()), + }, + ), // different heat — excluded + ( + Some(3), + Event::PenaltyApplied { + heat: HeatId(heat.into()), + competitor: CompetitorRef("A".into()), + penalty: Penalty::TimeAdded { micros: 2_000_000 }, + }, + ), + ]; + let entries = audit(&log, heat); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].kind, AuditKind::PenaltyApplied); + assert!(entries[0].summary.contains("+2.000s")); + // The competitor ref is carried STRUCTURED (for client callsign resolution), not baked into + // the summary string — the summary holds only the penalty description. + assert!(!entries[0].summary.contains('A')); + assert_eq!(entries[0].competitor, Some(CompetitorRef("A".into()))); + } + + #[test] + fn marshaling_log_is_deterministic_fold_twice() { + use gridfpv_events::Penalty; + let heat = "q-1"; + let log = vec![ + ( + Some(1), + Event::LapInserted { + adapter: AdapterId("vd".into()), + competitor: CompetitorRef("A".into()), + at: SourceTime::from_micros(3_000_000), + heat: None, + }, + ), + ( + Some(2), + Event::PenaltyApplied { + heat: HeatId(heat.into()), + competitor: CompetitorRef("B".into()), + penalty: Penalty::Disqualify { reason: None }, + }, + ), + (Some(3), Event::RulingReversed { target: LogRef(1) }), + ]; + assert_eq!(audit(&log, heat), audit(&log, heat)); + } + + // --- Signal trace (marshaling Slice 1) ---------------------------------------------------- + + use gridfpv_events::{SignalChunk, SignalThresholds}; + + fn chunk(adapter: &str, competitor: &str, from: i64, period: u32, rssi: &[u16]) -> Event { + Event::SignalChunk(SignalChunk { + adapter: AdapterId(adapter.into()), + competitor: CompetitorRef(competitor.into()), + from: SourceTime::from_micros(from), + period_micros: period, + rssi: rssi.to_vec(), + }) + } + + fn thresholds(adapter: &str, competitor: &str, enter: u16, exit: u16) -> Event { + Event::SignalThresholds(SignalThresholds { + adapter: AdapterId(adapter.into()), + competitor: CompetitorRef(competitor.into()), + enter, + exit, + }) + } + + #[test] + fn signal_trace_concatenates_chunks_in_log_order() { + // Two appended chunks for one node reconstruct into one contiguous sample buffer, anchored + // at the FIRST chunk's time base; thresholds fold in alongside. + let log = vec![ + thresholds("rh", "node-0", 90, 80), + chunk("rh", "node-0", 1_000_000, 100_000, &[70, 72, 150]), + chunk("rh", "node-0", 1_300_000, 100_000, &[148, 71, 70]), + ]; + let view = signal_trace(&log); + let key = CompetitorKey { + adapter: AdapterId("rh".into()), + competitor: CompetitorRef("node-0".into()), + }; + let trace = view.competitor(&key).expect("node-0 trace present"); + assert_eq!(trace.samples, vec![70, 72, 150, 148, 71, 70]); + assert_eq!(trace.from, Some(SourceTime::from_micros(1_000_000))); + assert_eq!(trace.period_micros, 100_000); + assert_eq!(trace.enter, Some(90)); + assert_eq!(trace.exit, Some(80)); + } + + #[test] + fn signal_trace_thresholds_are_last_writer_wins() { + let log = vec![ + thresholds("rh", "node-0", 90, 80), + thresholds("rh", "node-0", 95, 85), + ]; + let view = signal_trace(&log); + let trace = &view.competitors[0]; + assert_eq!((trace.enter, trace.exit), (Some(95), Some(85))); + } + + #[test] + fn signal_trace_ignores_passes_and_other_events() { + // A heat full of passes/lifecycle/marshaling with no signal facts projects empty. + let log = vec![ + pass("rh", "node-0", 1_000_000, Some(0)), + pass("rh", "node-0", 4_000_000, Some(1)), + voided(0), + ]; + assert!(signal_trace(&log).competitors.is_empty()); + } + + #[test] + fn signal_trace_is_per_competitor_and_key_ordered() { + let log = vec![ + chunk("rh", "node-1", 0, 100_000, &[60, 120]), + chunk("rh", "node-0", 0, 100_000, &[70, 150]), + ]; + let view = signal_trace(&log); + // Ordered by CompetitorKey (node-0 before node-1) regardless of arrival order. + let keys: Vec<&str> = view + .competitors + .iter() + .map(|c| c.competitor.competitor.0.as_str()) + .collect(); + assert_eq!(keys, vec!["node-0", "node-1"]); + } + + #[test] + fn signal_trace_fold_twice_identical() { + // Determinism-on-replay: the same log always yields the same view. + let log = vec![ + chunk("rh", "node-0", 1_000_000, 100_000, &[70, 150, 71]), + thresholds("rh", "node-0", 90, 80), + chunk("rh", "node-1", 1_000_000, 100_000, &[60, 120, 61]), + chunk("rh", "node-0", 1_300_000, 100_000, &[70]), + ]; + assert_eq!(signal_trace(&log), signal_trace(&log)); + } + + fn history(adapter: &str, competitor: &str, times: &[i64], rssi: &[u16]) -> Event { + Event::SignalHistory(SignalHistory { + adapter: AdapterId(adapter.into()), + competitor: CompetitorRef(competitor.into()), + times: times.to_vec(), + rssi: rssi.to_vec(), + }) + } + + #[test] + fn dense_history_supersedes_coarse_chunks() { + // A competitor with both coarse chunks AND a dense history: the view carries the DENSE trace + // (far more samples), not the streamed approximation — the prefer-dense rule. + let log = vec![ + thresholds("rh", "node-0", 90, 80), + // Coarse: two streamed samples. + chunk("rh", "node-0", 1_000_000, 100_000, &[70, 150]), + // Dense: the full per-tick history pulled at heat end (6 samples, finer grid). + history( + "rh", + "node-0", + &[0, 50_000, 100_000, 150_000, 200_000, 250_000], + &[70, 88, 150, 149, 90, 71], + ), + ]; + let view = signal_trace(&log); + let key = CompetitorKey { + adapter: AdapterId("rh".into()), + competitor: CompetitorRef("node-0".into()), + }; + let trace = view.competitor(&key).expect("node-0 trace"); + // The DENSE samples win, not the 2 coarse ones. + assert_eq!(trace.samples, vec![70, 88, 150, 149, 90, 71]); + // Grid derived from the dense times: from = first time, period = first inter-sample delta. + assert_eq!(trace.from, Some(SourceTime::from_micros(0))); + assert_eq!(trace.period_micros, 50_000); + // Thresholds still fold in alongside (independent of the trace source). + assert_eq!((trace.enter, trace.exit), (Some(90), Some(80))); + } + + #[test] + fn coarse_chunks_stand_without_a_dense_history() { + // No SignalHistory: the coarse chunks are the trace (a heat that ended before the pull). + let log = vec![chunk("rh", "node-0", 1_000_000, 100_000, &[70, 150, 71])]; + let view = signal_trace(&log); + let trace = &view.competitors[0]; + assert_eq!(trace.samples, vec![70, 150, 71]); + assert_eq!(trace.period_micros, 100_000); + } + + #[test] + fn empty_dense_history_does_not_blank_the_coarse_trace() { + // A pull that returned an empty history must NOT erase the coarse evidence already captured. + let log = vec![ + chunk("rh", "node-0", 0, 100_000, &[70, 150, 71]), + history("rh", "node-0", &[], &[]), + ]; + let trace = &signal_trace(&log).competitors[0]; + assert_eq!(trace.samples, vec![70, 150, 71]); + } + + #[test] + fn dense_history_last_writer_wins() { + // Two pulls for one competitor: the later dense history replaces the earlier one. + let log = vec![ + history("rh", "node-0", &[0, 100_000], &[70, 150]), + history("rh", "node-0", &[0, 50_000, 100_000], &[70, 88, 150]), + ]; + let trace = &signal_trace(&log).competitors[0]; + assert_eq!(trace.samples, vec![70, 88, 150]); + assert_eq!(trace.period_micros, 50_000); + } + + #[test] + fn dense_history_fold_is_order_independent() { + // Determinism: the dense/coarse choice is a pure function of which facts are present, not of + // fold order — the history before or after the chunk yields the same (dense) view. + let chunk_first = vec![ + chunk("rh", "node-0", 0, 100_000, &[70, 150]), + history("rh", "node-0", &[0, 50_000, 100_000], &[70, 88, 150]), + ]; + let history_first = vec![ + history("rh", "node-0", &[0, 50_000, 100_000], &[70, 88, 150]), + chunk("rh", "node-0", 0, 100_000, &[70, 150]), + ]; + assert_eq!(signal_trace(&chunk_first), signal_trace(&history_first)); + } + + #[test] + fn single_sample_dense_history_has_zero_period() { + let log = vec![history("rh", "node-0", &[42_000], &[150])]; + let trace = &signal_trace(&log).competitors[0]; + assert_eq!(trace.samples, vec![150]); + assert_eq!(trace.from, Some(SourceTime::from_micros(42_000))); + assert_eq!(trace.period_micros, 0); + } + + #[test] + fn dense_history_with_repeated_timestamp_derives_a_positive_period() { + // A live dense history (RH `history_values`) can repeat a timestamp — a peak reported at the + // same first/last time gives `[t, t, …]`. The grid period must skip the zero delta and use + // the first POSITIVE one, not collapse to a degenerate 0. + let log = vec![history( + "rh", + "node-0", + &[1_000, 1_000, 1_100, 1_100], + &[70, 150, 150, 71], + )]; + let trace = &signal_trace(&log).competitors[0]; + assert_eq!(trace.from, Some(SourceTime::from_micros(1_000))); + assert_eq!( + trace.period_micros, 100, + "first positive delta (1_100 - 1_000), skipping the leading 0" + ); + assert_eq!(trace.samples, vec![70, 150, 150, 71]); } } diff --git a/crates/projection/src/recalc.rs b/crates/projection/src/recalc.rs new file mode 100644 index 0000000..f6a7d2c --- /dev/null +++ b/crates/projection/src/recalc.rs @@ -0,0 +1,143 @@ +//! **Threshold recalculate** (RH plugin design D16, Slice 4 / marshaling #3) — re-run crossing +//! detection over a captured dense RSSI trace at marshal-chosen enter/exit levels, and *propose* the +//! resulting laps. +//! +//! RotorHazard exposes no "re-detect at new thresholds" call — the enter/exit hysteresis lives in +//! the node firmware, not the server. So the draggable-threshold recalculate is a **computation over +//! the stored trace** ([`SignalHistory`](gridfpv_events::SignalHistory) — the dense `times`/`rssi` +//! the plugin captured): replay the crossing rule at the marshal's levels and return the crossings. +//! Marshal commits the proposal; RH's truth is never mutated under the RD (Marshaling commit model). +//! +//! ## Fidelity (load-bearing — see the doc's risk #3) +//! +//! This replays the **enter→peak→exit** model: a crossing OPENS when RSSI rises to/above `enter`, +//! the pass time is the RSSI **peak** within the crossing, and the crossing CLOSES when RSSI falls +//! to/below `exit` (hysteresis: `enter > exit`). That matches RH's crossing shape, but the *exact* +//! pass-time RH's firmware reports (peak vs. a centroid/quad-interpolated instant) must be validated +//! against real RH on a captured trace before this drives committed marshaling — it is **not yet +//! wired** into the marshaling UI for that reason. + +/// One proposed lap/pass from a recalculation: the crossing's peak instant + peak RSSI. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProposedPass { + /// Pass time — the RSSI peak within the crossing, in the trace's own time unit (race-relative + /// microseconds, as carried by [`SignalHistory`](gridfpv_events::SignalHistory)). + pub at_micros: i64, + /// The peak RSSI reached during the crossing. + pub peak_rssi: u16, +} + +/// Re-detect crossings over a dense trace at `(enter, exit)` thresholds, returning the proposed +/// passes in time order. +/// +/// `times`/`rssi` are the parallel dense-trace arrays (the common prefix is used if they differ in +/// length). A crossing opens at the first sample `>= enter`, tracks the peak, and closes at the first +/// subsequent sample `<= exit`, emitting a pass at the peak. A crossing still open when the trace +/// ends is **not** emitted (no close = no recorded pass — matches RH recording on crossing close). +/// `enter <= exit` is a degenerate calibration (no hysteresis band); the same rule still runs. +pub fn redetect_passes(times: &[i64], rssi: &[u16], enter: u16, exit: u16) -> Vec { + let n = times.len().min(rssi.len()); + let mut out = Vec::new(); + let mut in_crossing = false; + let mut peak = 0u16; + let mut peak_t = 0i64; + for i in 0..n { + let v = rssi[i]; + let t = times[i]; + if !in_crossing { + if v >= enter { + in_crossing = true; + peak = v; + peak_t = t; + } + } else { + if v > peak { + peak = v; + peak_t = t; + } + if v <= exit { + out.push(ProposedPass { + at_micros: peak_t, + peak_rssi: peak, + }); + in_crossing = false; + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A trace of `count` bells (baseline→peak→baseline) spaced `gap` µs, at the given peak. Returns + /// (times, rssi). Each bell is 5 samples: baseline, mid, PEAK, mid, baseline. + fn bells(count: usize, gap: i64, peak: u16, baseline: u16) -> (Vec, Vec) { + let mid = (peak + baseline) / 2; + let mut t = Vec::new(); + let mut r = Vec::new(); + let mut clock = 0i64; + for _ in 0..count { + for &v in &[baseline, mid, peak, mid, baseline] { + t.push(clock); + r.push(v); + clock += gap; + } + clock += gap * 3; // dead air between bells + } + (t, r) + } + + #[test] + fn detects_one_pass_per_bell_at_the_peak() { + let (t, r) = bells(3, 100_000, 150, 70); + let passes = redetect_passes(&t, &r, 90, 80); + assert_eq!(passes.len(), 3, "three bells -> three passes"); + // Each pass lands at its bell's peak (the 3rd sample of each 5-sample bell + dead air). + assert!(passes.iter().all(|p| p.peak_rssi == 150)); + } + + #[test] + fn raising_enter_above_every_peak_yields_no_passes() { + let (t, r) = bells(3, 100_000, 150, 70); + assert!(redetect_passes(&t, &r, 200, 80).is_empty()); + } + + #[test] + fn a_higher_enter_rejects_a_low_false_bump() { + // Two real bells (peak 150) and one low false bump (peak 100). At enter 120 the false bump + // never crosses, so the recalc proposes only the two real passes — the marshaling win. + let (mut t, mut r) = bells(2, 100_000, 150, 70); + let (ft, fr) = bells(1, 100_000, 100, 70); + let base = t.last().copied().unwrap_or(0) + 300_000; + t.extend(ft.iter().map(|x| x + base)); + r.extend(fr); + assert_eq!( + redetect_passes(&t, &r, 90, 80).len(), + 3, + "low enter catches all three" + ); + assert_eq!( + redetect_passes(&t, &r, 120, 80).len(), + 2, + "higher enter drops the false bump" + ); + } + + #[test] + fn an_open_crossing_at_trace_end_is_not_a_pass() { + // Rises into a crossing but never falls back below exit before the trace ends. + let t = vec![0, 100_000, 200_000]; + let r = vec![70, 150, 150]; + assert!(redetect_passes(&t, &r, 90, 80).is_empty()); + } + + #[test] + fn mismatched_lengths_use_the_common_prefix() { + let t = vec![0, 100_000]; + let r = vec![70, 150, 70, 60]; + // Only the first two samples are considered (no close) -> no pass; just must not panic. + assert!(redetect_passes(&t, &r, 90, 80).is_empty()); + } +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml new file mode 100644 index 0000000..991e857 --- /dev/null +++ b/crates/server/Cargo.toml @@ -0,0 +1,66 @@ +[package] +name = "gridfpv-server" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[features] +# `live` pulls the dockerized-RotorHazard e2e harness (the testkit container + the +# engine's heat loop) into the server's *dev*-builds so `tests/full_event_live.rs` can +# drive a real event over real RH into the protocol server's log and observe it through a +# protocol client (HTTP snapshot + WS change-stream). It mirrors the engine's `live` +# feature: off by default so the core server stays Docker-free, and the e2e test is also +# `#[ignore]` so it never runs in the shared CI suite — only in the dedicated `rh-live` +# job (`cargo test -p gridfpv-server --features live --test full_event_live -- --ignored`). +# +# It enables only dev-dependencies (the test harness), so it adds nothing to the shipped +# library. `gridfpv-engine/live` pulls the adapters' Socket.IO transport the harness drives. +live = ["dep:gridfpv-testkit", "dep:gridfpv-adapters", "gridfpv-adapters/live", "gridfpv-engine/live"] + +[dependencies] +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) 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"] } + +# The smart SPA-or-typed-404 fallback (#64) drives an inner SPA `Service` (the Director's +# `ServeDir`) by hand, so it needs `tower`'s `Service`/`ServiceExt` in the *library* (not +# just dev-builds) — hence a regular dependency. `util` brings `ServiceExt::ready`. +tower = { version = "0.5", features = ["util"] } + +# 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" + +# The mock-RH e2e harness (#47), enabled only by the `live` feature. Optional so the +# default server build pulls no Docker/Socket.IO machinery; the `full_event_live` test +# drives it to stand up a real RotorHazard and feed a real event into the server's log. +# (A regular — not dev — dependency because cargo features can only gate `[dependencies]` +# via `dep:`; it is dead weight unless `live` is on, exactly like the engine's testkit dep.) +gridfpv-testkit = { workspace = true, optional = true } +# The RotorHazard transport (#47, `live` only): the e2e drives the dockerized RH the same +# way the engine's harness does — connect, reset, stage, drain the timer's real passes. +gridfpv-adapters = { workspace = true, optional = true } + +[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. +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +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 new file mode 100644 index 0000000..5680788 --- /dev/null +++ b/crates/server/src/app.rs @@ -0,0 +1,3877 @@ +//! 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=audit` / `?projection=result` — its [`LapList`] / marshaling audit trail / [`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::collections::BTreeSet; +use std::sync::{Arc, Mutex}; + +use axum::Json; +use axum::Router; +use axum::body::Body; +use axum::extract::{Path, Query, State}; +use axum::http::{Request, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post, put}; +use gridfpv_engine::format::{FormatRegistry, FormatSchema}; +use gridfpv_engine::scoring::{HeatResult, WinCondition, score_corrected_with_global_offsets}; +use gridfpv_events::{CompetitorRef, Event, HeatId, SourceTime}; +use gridfpv_projection::{ + AuditEntry, LapList, lap_list_marshaled, lap_list_marshaled_with_floor, marshaling_log, + registrations, signal_trace, +}; +use gridfpv_storage::{EventLog, Offset, Result as StorageResult, StoredEvent}; +use serde::Deserialize; +use tokio::sync::Notify; + +use crate::auth::{JoinTokenResponse, TokenStore}; +use crate::channels::ChannelCatalogEntry; +use crate::classes::{ + Class, ClassError, ClassErrorKind, CreateClassRequest, SetClassHiddenRequest, + UpdateClassRequest, +}; +use crate::control_handler::ControlAuth; +use crate::error::{ErrorCode, ProtocolError}; +use crate::events::{ + ActiveEvent, CreateEventRequest, EventMeta, EventRegistry, NewRoundReq, RegistryError, + RegistryErrorKind, RoundDef, RoundError, SetActiveEventRequest, SetClassMembershipRequest, + SetEventClassesRequest, SetEventRosterRequest, UpdateRoundReq, +}; +use crate::live_state::{ + HeatSummary, heat_summaries, live_state, live_state_over, live_state_over_with_floor, + with_heat_timing, +}; +use crate::pilots::{CreatePilotRequest, Pilot, PilotError, PilotErrorKind, UpdatePilotRequest}; +use crate::round_engine; +use crate::scope::{ClassId, EventId, PilotId}; +use crate::snapshot::{ProjectionBody, Snapshot}; +use crate::stream::Cursor; +use crate::timers::{ + CreateTimerRequest, SetEventTimersRequest, SetPrimaryTimerRequest, Timer, TimerId, + UpdateTimerRequest, +}; +use gridfpv_events::RoundId; + +/// 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`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, + /// 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, + /// The event's **open-practice live accumulator** (open-practice format, Slice 1): the + /// per-channel, in-memory (NOT logged) laps for an active open-practice heat. The source bridge + /// writes the heat's passes here instead of the log; the `/stream` live-state fold overlays its + /// computed [`LiveRaceState`](crate::live_state::LiveRaceState) so the non-logged laps still + /// drive the live view. `None` when no open-practice heat is active. Shared per the `AppState`'s + /// `Arc`s, so the bridge and the stream see the one cell. + open_practice: crate::open_practice::OpenPracticeLive, + /// The **command serialization lock** (release-hardening): every validated write — a control + /// command's validate→append, and each runtime driver's checked auto-append — holds this for + /// the whole read-check-append sequence, so a ruling can never land on a heat that went Final + /// between its validation and its append (the auto-official racing the RD), Finalize can't + /// slip past a concurrent FileProtest, and two ScheduleHeats can't both pass the duplicate-id + /// check. Ordering: this lock is ALWAYS taken before the log mutex, never while holding it. + /// Raw pass appends (the source bridge) bypass it — they validate nothing. + commands: Arc>, +} + +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)), + appended: Arc::new(Notify::new()), + tokens: TokenStore::new(), + open_practice: crate::open_practice::OpenPracticeLive::new(), + commands: Arc::new(Mutex::new(())), + } + } + + /// Hold the command serialization lock for a validate→append sequence (see the field doc). + /// Callers MUST NOT already hold the log mutex. A poisoned lock is recovered — the guard + /// protects ordering, not data. + pub fn command_guard(&self) -> std::sync::MutexGuard<'_, ()> { + self.commands + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Append `event` iff `check` still passes over the current log, all under the command + /// serialization lock — the runtime drivers' fire-time recheck (a cancelled-but-in-flight + /// driver's stale transition must not land after a manual command changed the heat's state). + /// Returns `Ok(None)` when the check rejected (nothing appended). + pub fn append_checked( + &self, + event: Event, + recorded_at: Option, + check: impl FnOnce(&[Event]) -> bool, + ) -> Result, ProtocolError> { + let _guard = self.command_guard(); + let (events, _cursor) = self.read()?; + if !check(&events) { + return Ok(None); + } + self.append(event, recorded_at).map(Some) + } + + /// 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, + appended: Arc::new(Notify::new()), + tokens: TokenStore::new(), + open_practice: crate::open_practice::OpenPracticeLive::new(), + commands: Arc::new(Mutex::new(())), + } + } + + /// Build the state from a concrete log backend while **sharing** an existing + /// [`TokenStore`] — used by the [`EventRegistry`](crate::events::EventRegistry) so every + /// per-event [`AppState`] consults the one auth authority (an RD token authenticates + /// control on any event; a join token reads any event). Each event keeps its **own** log + /// and its own append-notify, but the token store is one Director-wide gate. + pub fn with_tokens(log: impl EventLog + Send + 'static, tokens: TokenStore) -> Self { + Self { + log: Arc::new(Mutex::new(log)), + appended: Arc::new(Notify::new()), + tokens, + open_practice: crate::open_practice::OpenPracticeLive::new(), + commands: Arc::new(Mutex::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) + } + + /// 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 { + // Server-authoritative race clock (#62 follow-up): a heat's `Armed → Running` and + // `Running → Unofficial` transitions are the race-start / race-end instants the live + // clock anchors to, and a heat's `HeatStarting` is the **arm instant** the start-tone + // countdown anchors to (`tone_at` = its `recorded_at` + the logged `delay_ms`, #249). The + // runtime/control paths append all of these with no caller timestamp, so stamp the server + // wall clock here (the single append choke point) when one is absent — making the event's + // `recorded_at` the authoritative timing every client reads. Without this the `HeatStarting` + // entry is untimed and `heat_tone_at` yields `None`, so the Armed-phase countdown never shows. + // A caller-supplied timestamp (a replay, a test pinning an instant) still wins. + let recorded_at = recorded_at.or_else(|| { + matches!( + event, + Event::HeatStateChanged { .. } | Event::HeatStarting { .. } + ) + .then(now_micros) + }); + 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) + } + + /// The event's **open-practice live accumulator** (open-practice format, Slice 1) — the shared + /// per-channel, in-memory (NOT logged) lap store. The source bridge writes an open-practice + /// heat's passes here (via [`OpenPracticeLive::record`](crate::open_practice::OpenPracticeLive::record)); + /// the `/stream` live-state fold overlays its computed live state. Cloning shares the one cell. + pub fn open_practice(&self) -> crate::open_practice::OpenPracticeLive { + self.open_practice.clone() + } + + /// **Wake every subscribed change stream** without appending to the log — the non-log push the + /// open-practice live delivery uses (open-practice format, Slice 1). + /// + /// An open-practice heat's laps are accumulated in memory (not logged), so they never reach the + /// log's append-notify; after mutating the [`open_practice`](Self::open_practice) accumulator the + /// bridge calls this so a parked stream re-folds and pushes a fresh-value + /// [`LiveRaceState`](crate::live_state::LiveRaceState) envelope reflecting the new per-channel + /// laps — reusing the exact same wakeup `append` uses, just without a log write. + pub fn wake_streams(&self) { + self.appended.notify_waiters(); + } + + /// 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. + 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") + })?; + 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)) + } + + /// Read the whole log as [`StoredEvent`]s (carrying each entry's `recorded_at`) plus the + /// resume [`Cursor`]. Like [`read`](Self::read) but keeps the server timestamps the + /// live-state clock is anchored to (the `Running` / `Unofficial` transition instants — + /// see [`live_state::with_heat_timing`](crate::live_state::with_heat_timing)). + pub(crate) fn read_stored(&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()))?, + ); + Ok((stored, cursor)) + } +} + +/// Build the **event-rooted** protocol [`Router`] over the [`EventRegistry`] (issue #72). +/// +/// Every read/realtime/control surface is rooted under its event — `/events/{eventId}/…` — +/// and the handler resolves `eventId` to that event's [`AppState`] (its own log) via the +/// registry before serving (mirroring the within-event scope filtering: heat window, pilot, +/// etc.). The events lifecycle API (`GET /events`, `POST /events`) and a liveness +/// `GET /health` sit at the root. An unknown `eventId` is a typed [`ProtocolError`] 404 +/// (`UnknownScope`), the same shape a wrong route gets (#64). +pub fn router(registry: EventRegistry) -> Router { + // The per-event surface, rooted under `/events/{event_id}`. Each handler resolves the + // event id to its own `AppState`/log through the registry. + let read = Router::new() + .route("/health", get(|| async { "ok" })) + // The product identity (alpha field-support): WHICH build is this rig running? The + // console footer reads it, and a bug report from the field should quote it. The + // version is the ONE workspace version (v0.4.0-alpha.1 scheme, `cargo xtask version`); + // the contract version is the independent wire-compat integer. + .route( + "/about", + get(|| async { + Json(serde_json::json!({ + "name": "GridFPV", + "version": env!("CARGO_PKG_VERSION"), + "contract_version": crate::CONTRACT_VERSION, + })) + }), + ) + // The Director's wall clock, in epoch microseconds (open, no auth). The console measures its + // offset from this (round-trip-corrected) so the start countdown + race clock read off + // *server* time, not the RD device's clock — which can differ by ~1s on a separate laptop and + // otherwise makes the Armed countdown bottom out early. See `serverNowMs` in session.svelte.ts. + .route( + "/time", + get(|| async { Json(serde_json::json!({ "now_micros": now_micros() })) }), + ) + // Events lifecycle (issue #72): list (Practice first) and RD-gated create. + .route("/events", get(list_events).post(create_event)) + // RD-gated **permanent** delete of an event + ALL its data (the papercut fix): the + // registry entry, the persisted `.sqlite`(+wal/shm), and the active pointer if it + // pointed here. The built-in Practice event cannot be deleted (a `BadRequest`); an + // unknown id is a typed 404. + .route("/events/{event_id}", axum::routing::delete(delete_event)) + // The Director's active event (issue #90): an open read so any client resumes into the + // selected event on connect/reload, and an RD-gated write to set it. + .route("/active-event", get(get_active_event).put(set_active_event)) + // Application-level timers (issue #73): the persisted registry the RD configures once and + // each event selects from. `GET /timers` is an open read (Mock first); create/edit/ + // delete are RD-gated. `DELETE` rejects the built-in Mock. + .route("/timers", get(list_timers).post(create_timer)) + .route("/timers/{timer_id}", put(update_timer).delete(delete_timer)) + // The downloadable GridFPV RotorHazard plugin bundle (D16, S1) the guided-install UX + // offers when a timer's plugin is missing/incompatible. Open read: it's static, embedded + // at build, and carries no event data — just the plugin folder to drop into RH's plugins/. + .route("/plugin/gridfpv.zip", get(download_plugin_bundle)) + // Application-level pilots (issue #74): the persisted directory the RD maintains once and + // each event rosters from. `GET /pilots` is an open read; create/edit/delete are RD-gated. + .route("/pilots", get(list_pilots).post(create_pilot)) + .route("/pilots/{pilot_id}", put(update_pilot).delete(delete_pilot)) + // Application-level classes (issue #84): the persisted directory the RD maintains once and + // each event selects from. `GET /classes` is an open read; create/edit/delete are RD-gated. + .route("/classes", get(list_classes).post(create_class)) + .route( + "/classes/{class_id}", + put(update_class).delete(delete_class), + ) + // Hide/archive a class (hide/archive classes): a control-gated visibility toggle, valid for + // built-in + custom classes. The id stays in the directory; hiding only filters it from the + // per-event class picker. Persisted to a sidecar so a hidden built-in survives the re-seed. + .route("/classes/{class_id}/hidden", put(set_class_hidden)) + // The valid **format names** (race redesign Slice 2b): the single source of truth the + // Rounds UI's format dropdown reads, straight from [`FormatRegistry::standard`]. An open + // read (no token) — it is static configuration, not event state. + .route("/formats", get(list_formats)) + // The standard **FPV channel catalog** (race redesign Slice 4b): the shared band/channel ↔ + // raw-MHz vocabulary the Channels UI offers (a timer's available-channels picker) and reads + // back to label a heat's assigned frequencies. An open read (no token) — static, compiled-in + // configuration like `/formats`, not per-event state. + .route("/channels", get(list_channels)) + // Per-event class **selection** (issue #84): RD-gated; each id must name a known directory + // class. Set the whole selection wholesale (mirrors the timer selection). + .route("/events/{event_id}/classes", put(set_event_classes)) + // Per-class **membership** (race redesign Slice 1a): RD-gated; the class must be selected by + // the event and each pilot id must name a known directory pilot. Replaces that class's pilot + // list wholesale (an empty list clears it). + .route( + "/events/{event_id}/classes/{class_id}/membership", + put(set_class_membership), + ) + // Per-event **rounds** (race redesign Slice 2a): RD-gated. Add a round (POST, id + // generated), or update/remove an existing one by its generated round id. Each class must be + // selected by the event, the format must be known, and a `FromRanking` seeding source must + // name an existing round. + .route("/events/{event_id}/rounds", post(add_round)) + .route( + "/events/{event_id}/rounds/{round_id}", + put(update_round).delete(remove_round), + ) + // Per-event scheduled **heats** (race redesign Slice 3b): the round-tagged heats list the + // Heats UI reads — open, no token (a read), like the snapshot routes. + .route("/events/{event_id}/heats", get(list_heats)) + // The event-wide **audit trail** (the "defensible results" review surface): every heat's + // marshaling audit fold, heat-tagged and merged newest-first — what the console's Audit + // page reads. Open, no token (a read, like the heats list and the snapshot routes). + .route("/events/{event_id}/audit", get(event_audit)) + // A round's **ranking** (race redesign Slice 5/6a): the ordered per-pilot ranking the + // engine seeds `FromRanking` from — what the bracket-carry UI displays. Open, no token. + .route( + "/events/{event_id}/rounds/{round_id}/ranking", + get(round_ranking), + ) + // A round's **standings** (time-trial / qual display): the per-pilot rows for a round — each + // pilot's best lap plus the win-condition metric they're ranked on, in ranking order. Open, + // no token (a read, like the ranking route). + .route( + "/events/{event_id}/rounds/{round_id}/standings", + get(round_standings), + ) + // A class's **standings** (race redesign Slice 5/6a): the per-pilot rows aggregated across + // the class's rounds (the season-join shape the Results UI reads). Open, no token. + .route( + "/events/{event_id}/classes/{class_id}/standings", + get(class_standings), + ) + // Per-event **roster** (issue #74): RD-gated; each id must name a known directory pilot. + // Set the whole roster, or add/remove a single pilot. + .route("/events/{event_id}/roster", put(set_event_roster)) + .route( + "/events/{event_id}/roster/{pilot_id}", + post(add_to_roster).delete(remove_from_roster), + ) + // Per-event timer **selection** (issue #73): RD-gated; each id must name a known timer. + .route("/events/{event_id}/timers", put(set_event_timers)) + // Per-event **primary** timer (issue #112): RD-gated; the id must be in the selection. + .route( + "/events/{event_id}/primary-timer", + put(set_event_primary_timer), + ) + // Per-event read/realtime surface — `{event_id}` resolves to that event's log. + .route( + "/events/{event_id}/snapshot/event/{event}", + get(snapshot_event), + ) + .route( + "/events/{event_id}/snapshot/class/{event}/{class}", + get(snapshot_class), + ) + .route( + "/events/{event_id}/snapshot/heat/{heat}", + get(snapshot_heat), + ) + .route( + "/events/{event_id}/snapshot/pilot/{event}/{pilot}", + get(snapshot_pilot), + ) + .route("/events/{event_id}/stream", get(crate::ws::stream_handler)) + // RD-gated mint of a read-only join token (#63), now per-event. + .route("/events/{event_id}/auth/join-token", post(mint_join_token)); + // The privileged RD control surface (§5) is composed on separately so its auth layer + // wraps just `/events/{event_id}/control`. + crate::control_handler::control_routes(read) + // Any path under a known API tree that matched no route above is a typed 404 + // (#64), not the SPA shell — see [`api_fallback`] / [`smart_fallback`]. + .fallback(api_fallback) + .with_state(registry) +} + +/// Resolve an [`EventId`] to its [`AppState`] through the registry, or a typed 404. +/// +/// The single resolution point every per-event handler funnels through: an unknown event id +/// is an [`ErrorCode::UnknownScope`] [`ProtocolError`] (HTTP 404), mirroring an unknown heat +/// / pilot, so a client reads one uniform shape whether the *event* or a *scope within it* +/// is missing. +pub fn resolve_event(registry: &EventRegistry, id: &EventId) -> Result { + registry.resolve(id).ok_or_else(|| { + ProtocolError::new( + ErrorCode::UnknownScope, + format!("no event with id {:?}", id.0), + ) + }) +} + +/// `GET /events` — list every event's [`EventMeta`], Practice first (issue #72). +/// +/// Reads are open on the LAN (§5), so listing events needs no token — a spectator can see +/// which events exist before scoping into one. +async fn list_events(State(registry): State) -> Json> { + Json(registry.list()) +} + +/// `POST /events` — create a new event from a [`CreateEventRequest`], RD-gated (issue #72). +/// +/// [`ControlAuth`] runs first: only an authenticated **RD** may create an event (and with +/// full-trust by default the control gate is open until a token is configured — #72 Slice 1b). +/// The id is **auto-generated** (a slug of the name + a short random suffix) — names are +/// display-only, ids are never user-entered. The request's optional descriptive fields +/// (`date`/`location`/`description`/`organizer`) are stored on the new event's meta. The event +/// gets its own SQLite-backed log under the configured data dir (or an in-memory log when none +/// is configured) and the freshly-created [`EventMeta`] is returned. +async fn create_event( + _auth: ControlAuth, + State(registry): State, + Json(body): Json, +) -> Result, ProtocolError> { + let meta = registry + .create(&body) + .map_err(|e| ProtocolError::new(ErrorCode::Internal, e.to_string()))?; + Ok(Json(meta)) +} + +/// `DELETE /events/{event_id}` — **permanently** delete an event and ALL of its data, RD-gated +/// (the papercut fix). +/// +/// [`ControlAuth`] runs first (the same gate as create; open in full-trust by default). The +/// delete is total and irreversible: the registry entry, the event's persisted state (its +/// `.sqlite` log plus the WAL/SHM sidecars under the data dir), and the active-event pointer +/// if it pointed at this event are all removed — so nothing of it survives a restart. The +/// built-in **Practice** event cannot be deleted (a `BadRequest`); an unknown id is a typed 404 +/// (`UnknownScope`). On success an empty 200 is returned (no body — like the other deletes). +async fn delete_event( + _auth: ControlAuth, + State(registry): State, + Path(event_id): Path, +) -> Result { + // The registry types the failure: a protected-Practice delete is a client error (BadRequest), + // a genuinely unknown id is a 404, and a file-removal (I/O) failure is a 500 — note the + // in-memory drop already happened, so an I/O failure must NOT read as a 404. + registry + .delete(&event_id) + .map_err(registry_error_to_protocol)?; + Ok(StatusCode::OK) +} + +/// `GET /active-event` — the Director's currently-active event, or `null` (issue #90). +/// +/// An **open read** (no token): every client — RD console, pilot view, read-only spectator — +/// reads this on connect/reload to resume into the selected event (or fall to the picker when +/// `null`). The active event is Director state, not per-client browser state, so a reload / +/// reconnect / app-restart resumes into the same event all clients are on. +async fn get_active_event(State(registry): State) -> Json { + Json(ActiveEvent { + event: registry.active(), + }) +} + +/// `PUT /active-event` — set the Director's active event, RD-gated (issue #90). +/// +/// [`ControlAuth`] runs first (the same gate as every other control write): only an +/// authenticated RD may change which event the Director is on (open in full-trust by default). +/// The body's `id` must name a known event, else a typed 404 (`UnknownScope`). On success the +/// active event is persisted server-side (surviving a Director restart) and its [`EventMeta`] +/// is returned. +async fn set_active_event( + _auth: ControlAuth, + State(registry): State, + Json(body): Json, +) -> Result, ProtocolError> { + let meta = registry + .set_active(&body.id) + .map_err(registry_error_to_protocol)?; + Ok(Json(meta)) +} + +/// `GET /timers` — list every configured timer, **Mock first** (issue #73). +/// +/// An **open read** (no token): a client renders the timer picker / per-event selection without a +/// credential, mirroring `GET /events`. +async fn list_timers(State(registry): State) -> Json> { + Json(registry.timers().list()) +} + +/// `GET /plugin/gridfpv.zip` — the downloadable GridFPV RotorHazard plugin bundle (D16, S1). +/// +/// An **open read**: the guided-install UX offers it when a timer's plugin is missing/incompatible. +/// The bytes are a STORE-only ZIP built from the plugin source embedded at compile time +/// ([`plugin_bundle`](crate::plugin_bundle)), so the download always matches this Director's +/// protocol version. Served as an attachment so the browser saves it. +async fn download_plugin_bundle() -> Response { + let zip = crate::plugin_bundle::plugin_zip(); + ( + [ + (axum::http::header::CONTENT_TYPE, "application/zip"), + ( + axum::http::header::CONTENT_DISPOSITION, + concat!("attachment; filename=\"", "gridfpv-plugin.zip", "\""), + ), + ], + zip, + ) + .into_response() +} + +/// `POST /timers` — create a timer from a [`CreateTimerRequest`], RD-gated (issue #73). +/// +/// [`ControlAuth`] runs first (open in full-trust by default). The id is auto-generated +/// server-side (a name slug + suffix); the new [`Timer`] is returned and the registry persisted. +async fn create_timer( + _auth: ControlAuth, + State(registry): State, + Json(body): Json, +) -> Result, ProtocolError> { + // Reject a bad config up front as a 400 (release-hardening P2): a 0 node count, an empty RH URL, + // or a runaway Mock laps count. + let node_count = body.node_count.unwrap_or(crate::timers::DEFAULT_NODE_COUNT); + crate::timers::validate_timer_config(&body.kind, node_count) + .map_err(|msg| ProtocolError::new(ErrorCode::BadRequest, msg))?; + let timer = registry + .timers() + .create(&body) + .map_err(|e| ProtocolError::new(ErrorCode::Internal, e.to_string()))?; + Ok(Json(timer)) +} + +/// `PUT /timers/{timer_id}` — edit a timer's name/config, RD-gated (issue #73). +/// +/// The built-in Mock may be retuned but not removed (that is `DELETE`'s concern). An unknown +/// id is a typed 404 (`UnknownScope`). On success the updated [`Timer`] is returned and the +/// registry persisted. +async fn update_timer( + _auth: ControlAuth, + State(registry): State, + Path(timer_id): Path, + Json(body): Json, +) -> Result, ProtocolError> { + // Validate the *effective* post-edit config as a 400 (release-hardening P2): merge the partial + // request onto the existing timer so a partial edit (e.g. just `node_count: 0`) is still caught. + // A nonexistent id is left to `update` to report as a 404. + if let Some(existing) = registry.timers().get(&timer_id) { + let kind = body.kind.clone().unwrap_or(existing.kind); + let node_count = body.node_count.unwrap_or(existing.node_count); + crate::timers::validate_timer_config(&kind, node_count) + .map_err(|msg| ProtocolError::new(ErrorCode::BadRequest, msg))?; + } + let timer = registry + .timers() + .update(&timer_id, &body) + .map_err(|e| ProtocolError::new(ErrorCode::UnknownScope, e.to_string()))?; + Ok(Json(timer)) +} + +/// `DELETE /timers/{timer_id}` — remove a timer, RD-gated (issue #73). +/// +/// The built-in **Mock cannot be deleted** (it is always present) — attempting to is a +/// `BadRequest`; an unknown id is a 404 (`UnknownScope`). On success an empty 200 is returned and +/// the registry persisted. +async fn delete_timer( + _auth: ControlAuth, + State(registry): State, + Path(timer_id): Path, +) -> Result { + // The protected-Mock delete is a client error (BadRequest); a genuinely unknown id is a + // 404. Distinguish by whether the timer exists at all. + registry.timers().delete(&timer_id).map_err(|e| { + let code = if registry.timers().exists(&timer_id) { + ErrorCode::BadRequest + } else { + ErrorCode::UnknownScope + }; + ProtocolError::new(code, e.to_string()) + })?; + Ok(StatusCode::OK) +} + +/// `PUT /events/{event_id}/timers` — set an event's **selected timers** (issue #73) and optionally +/// the **primary** among them (issue #112), RD-gated. +/// +/// [`ControlAuth`] runs first. The event must exist (else a typed 404) and **each** id in the body +/// must name a known timer in the registry (else a 404 naming the bad id) — so an event can never +/// reference a deleted/unknown timer. When a `primary` is given it must be one of `ids` (else a +/// 400). On success the updated [`EventMeta`] is returned. +async fn set_event_timers( + _auth: ControlAuth, + State(registry): State, + Path(event_id): Path, + Json(body): Json, +) -> Result, ProtocolError> { + // Validate every selected timer exists before recording the selection. + let timers = registry.timers(); + for id in &body.ids { + if !timers.exists(id) { + return Err(ProtocolError::new( + ErrorCode::UnknownScope, + format!("no timer with id {:?}", id.0), + )); + } + } + // A primary, if given, must be one of the timers being selected (issue #112). + if let Some(primary) = &body.primary { + if !body.ids.contains(primary) { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "primary timer {:?} is not in the selected timers", + primary.0 + ), + )); + } + } + registry + .set_timers(&event_id, body.ids) + .map_err(registry_error_to_protocol)?; + // Record the primary in the same request (it is now guaranteed in the just-set selection). + let meta = registry + .set_primary_timer(&event_id, body.primary) + .map_err(registry_error_to_protocol)?; + Ok(Json(meta)) +} + +/// `PUT /events/{event_id}/primary-timer` — designate an event's **primary** timer, RD-gated +/// (issue #112). +/// +/// [`ControlAuth`] runs first. The event must exist (else a typed 404). When `id` is given it must +/// be one of the event's **currently-selected** timers (else a 400); `null` clears the override so +/// the first selected timer becomes the effective primary. On success the updated [`EventMeta`] is +/// returned. The per-event source bridge reads the primary live, so a change fails over the active +/// source on the next poll. +async fn set_event_primary_timer( + _auth: ControlAuth, + State(registry): State, + Path(event_id): Path, + Json(body): Json, +) -> Result, ProtocolError> { + let meta = registry + .set_primary_timer(&event_id, body.id) + .map_err(registry_error_to_protocol)?; + Ok(Json(meta)) +} + +/// `GET /pilots` — list every pilot in the directory, in id order (issue #74). +/// +/// An **open read** (no token): a client renders the pilot directory / per-event roster picker +/// without a credential, mirroring `GET /timers`. +async fn list_pilots(State(registry): State) -> Json> { + Json(registry.pilots().list()) +} + +/// `POST /pilots` — create a pilot from a [`CreatePilotRequest`], RD-gated (issue #74). +/// +/// [`ControlAuth`] runs first (open in full-trust by default). The `callsign` is required; the id +/// is auto-generated server-side (a callsign slug + suffix). The new [`Pilot`] is returned and the +/// directory persisted. A missing/blank callsign is a `BadRequest`. +async fn create_pilot( + _auth: ControlAuth, + State(registry): State, + Json(body): Json, +) -> Result, ProtocolError> { + let pilot = registry + .pilots() + .create(&body) + .map_err(pilot_error_to_protocol)?; + Ok(Json(pilot)) +} + +/// `PUT /pilots/{pilot_id}` — edit a pilot's callsign/metadata, RD-gated (issue #74). +/// +/// An unknown id is a typed 404 (`UnknownScope`). On success the updated [`Pilot`] is returned and +/// the directory persisted. +async fn update_pilot( + _auth: ControlAuth, + State(registry): State, + Path(pilot_id): Path, + Json(body): Json, +) -> Result, ProtocolError> { + let pilot = registry + .pilots() + .update(&pilot_id, &body) + .map_err(pilot_error_to_protocol)?; + Ok(Json(pilot)) +} + +/// `DELETE /pilots/{pilot_id}` — remove a pilot from the directory, RD-gated (issue #74). +/// +/// An unknown id is a 404 (`UnknownScope`). On success an empty 200 is returned and the directory +/// persisted. A stale roster id on some event is harmless (rosters tolerate an unknown id). +async fn delete_pilot( + _auth: ControlAuth, + State(registry): State, + Path(pilot_id): Path, +) -> Result { + registry + .pilots() + .delete(&pilot_id) + .map_err(pilot_error_to_protocol)?; + Ok(StatusCode::OK) +} + +/// Map a [`RegistryError`] to a typed [`ProtocolError`] (release-hardening P1-7): an unknown id is +/// an `UnknownScope` (404), a bad request is a `BadRequest` (400), and an **I/O / persistence** +/// failure is `Internal` (500). The last is the load-bearing case: the in-memory state is mutated +/// before the write-through, so a failed write must surface as a 500 (not a 404/400) — the change +/// did not durably land. Mirrors [`pilot_error_to_protocol`] / [`class_error_to_protocol`]. +fn registry_error_to_protocol(error: RegistryError) -> ProtocolError { + let code = match error.kind { + RegistryErrorKind::NotFound => ErrorCode::UnknownScope, + RegistryErrorKind::Invalid => ErrorCode::BadRequest, + RegistryErrorKind::Io => ErrorCode::Internal, + }; + ProtocolError::new(code, error.to_string()) +} + +/// Map a [`PilotError`] to a typed [`ProtocolError`] (issue #74): a validation failure is a +/// `BadRequest` (400), an unknown id is an `UnknownScope` (404), and a persistence failure is +/// `Internal` (500). +fn pilot_error_to_protocol(error: PilotError) -> ProtocolError { + let code = match error.kind { + PilotErrorKind::Invalid => ErrorCode::BadRequest, + PilotErrorKind::NotFound => ErrorCode::UnknownScope, + PilotErrorKind::Internal => ErrorCode::Internal, + }; + ProtocolError::new(code, error.to_string()) +} + +/// `PUT /events/{event_id}/roster` — set an event's **roster** (issue #74), RD-gated. +/// +/// [`ControlAuth`] runs first. The event must exist (else a typed 404) and **each** id in the body +/// must name a known directory pilot (else a 404 naming the bad id) — so a roster can never +/// reference a deleted/unknown pilot. On success the updated [`EventMeta`] is returned. +async fn set_event_roster( + _auth: ControlAuth, + State(registry): State, + Path(event_id): Path, + Json(body): Json, +) -> Result, ProtocolError> { + let pilots = registry.pilots(); + for id in &body.pilot_ids { + if !pilots.exists(id) { + return Err(ProtocolError::new( + ErrorCode::UnknownScope, + format!("no pilot with id {:?}", id.0), + )); + } + } + let meta = registry + .set_roster(&event_id, body.pilot_ids) + .map_err(registry_error_to_protocol)?; + Ok(Json(meta)) +} + +/// `POST /events/{event_id}/roster/{pilot_id}` — add one pilot to an event's roster (issue #74), +/// RD-gated. The event must exist and the pilot must name a known directory pilot (else a 404). +/// Idempotent. On success the updated [`EventMeta`] is returned. +async fn add_to_roster( + _auth: ControlAuth, + State(registry): State, + Path((event_id, pilot_id)): Path<(EventId, PilotId)>, +) -> Result, ProtocolError> { + if !registry.pilots().exists(&pilot_id) { + return Err(ProtocolError::new( + ErrorCode::UnknownScope, + format!("no pilot with id {:?}", pilot_id.0), + )); + } + let meta = registry + .add_to_roster(&event_id, pilot_id) + .map_err(registry_error_to_protocol)?; + Ok(Json(meta)) +} + +/// `DELETE /events/{event_id}/roster/{pilot_id}` — remove one pilot from an event's roster +/// (issue #74), RD-gated. The event must exist (else a 404); removing a pilot not on the roster is +/// a no-op (the pilot need not still exist in the directory). On success the updated [`EventMeta`] +/// is returned. +async fn remove_from_roster( + _auth: ControlAuth, + State(registry): State, + Path((event_id, pilot_id)): Path<(EventId, PilotId)>, +) -> Result, ProtocolError> { + let meta = registry + .remove_from_roster(&event_id, &pilot_id) + .map_err(registry_error_to_protocol)?; + Ok(Json(meta)) +} + +/// `GET /classes` — list every class in the directory, in id order (issue #84). +/// +/// An **open read** (no token): a client renders the class directory / per-event selection picker +/// without a credential, mirroring `GET /pilots`. +async fn list_classes(State(registry): State) -> Json> { + Json(registry.classes().list()) +} + +/// `GET /formats` — the valid **formats + their param schemas** (race redesign Slice 2b / 7a). +/// +/// The single source of truth the Rounds UI reads: each production format +/// ([`FormatRegistry::standard`]) with the **param schema** its generator consumes +/// ([`FormatRegistry::standard_schemas`]) — `{ name, params: [{ key, label, kind, options?, +/// default? }] }` — so the UI renders both the format dropdown and a per-format params editor. An +/// open read (no token) — static, compiled-in configuration, not per-event state. +async fn list_formats() -> Json> { + Json(FormatRegistry::standard_schemas()) +} + +/// `GET /channels` — the standard **FPV channel catalog** (race redesign Slice 4b). +/// +/// The shared band/channel ↔ raw-MHz vocabulary the Channels UI reads: it offers these +/// human-readable labels when a Race Director picks a Flexible timer's available channels, and +/// resolves a heat's assigned raw frequency back to a band+channel label. An open read (no token) — +/// static, compiled-in configuration straight from [`crate::channels::catalog`], not event state. +async fn list_channels() -> Json> { + Json(crate::channels::catalog()) +} + +/// `POST /classes` — create a class from a [`CreateClassRequest`], RD-gated (issue #84). +/// +/// [`ControlAuth`] runs first (open in full-trust by default). The `name` is required; the id is +/// auto-generated server-side (a name slug + suffix). The new [`Class`] is returned and the +/// directory persisted. A missing/blank name is a `BadRequest`. +async fn create_class( + _auth: ControlAuth, + State(registry): State, + Json(body): Json, +) -> Result, ProtocolError> { + let class = registry + .classes() + .create(&body) + .map_err(class_error_to_protocol)?; + Ok(Json(class)) +} + +/// `PUT /classes/{class_id}` — edit a class's name/source/metadata, RD-gated (issue #84). +/// +/// An unknown id is a typed 404 (`UnknownScope`). On success the updated [`Class`] is returned and +/// the directory persisted. +async fn update_class( + _auth: ControlAuth, + State(registry): State, + Path(class_id): Path, + Json(body): Json, +) -> Result, ProtocolError> { + let class = registry + .classes() + .update(&class_id, &body) + .map_err(class_error_to_protocol)?; + Ok(Json(class)) +} + +/// `DELETE /classes/{class_id}` — remove a class from the directory, RD-gated (issue #84). +/// +/// An unknown id is a 404 (`UnknownScope`). On success an empty 200 is returned and the directory +/// persisted. A stale selection id on some event is harmless (selections tolerate an unknown id). +async fn delete_class( + _auth: ControlAuth, + State(registry): State, + Path(class_id): Path, +) -> Result { + registry + .classes() + .delete(&class_id) + .map_err(class_error_to_protocol)?; + Ok(StatusCode::OK) +} + +/// `PUT /classes/{class_id}/hidden` — hide or un-hide a class (hide/archive classes), RD-gated. +/// +/// [`ControlAuth`] runs first. The body is `{ hidden: bool }`. Hiding is a **visibility +/// preference**, not an edit, so it is valid for **built-in** classes too (never a read-only +/// rejection): the class stays in the directory and the main Classes view; it is just filtered out +/// of the per-event class picker. The choice is persisted to a sidecar so a hidden built-in survives +/// the boot re-seed. An unknown id is a typed 404 (`UnknownScope`). On success the updated [`Class`] +/// (with its fresh `hidden` flag) is returned. +async fn set_class_hidden( + _auth: ControlAuth, + State(registry): State, + Path(class_id): Path, + Json(body): Json, +) -> Result, ProtocolError> { + let class = registry + .classes() + .set_hidden(&class_id, body.hidden) + .map_err(class_error_to_protocol)?; + Ok(Json(class)) +} + +/// Map a [`ClassError`] to a typed [`ProtocolError`] (issue #84): a validation failure is a +/// `BadRequest` (400), an unknown id is an `UnknownScope` (404), and a persistence failure is +/// `Internal` (500) — mirroring [`pilot_error_to_protocol`]. +fn class_error_to_protocol(error: ClassError) -> ProtocolError { + let code = match error.kind { + ClassErrorKind::Invalid => ErrorCode::BadRequest, + // A read-only built-in edit/delete is a rejected bad request (the built-in is canonical). + ClassErrorKind::ReadOnly => ErrorCode::BadRequest, + ClassErrorKind::NotFound => ErrorCode::UnknownScope, + ClassErrorKind::Internal => ErrorCode::Internal, + }; + ProtocolError::new(code, error.to_string()) +} + +/// `PUT /events/{event_id}/classes` — set an event's **class selection** (issue #84), RD-gated. +/// +/// [`ControlAuth`] runs first. The event must exist (else a typed 404) and **each** id in the body +/// must name a known directory class (else a 404 naming the bad id) — so a selection can never +/// reference a deleted/unknown class. On success the updated [`EventMeta`] is returned. Mirrors +/// `set_event_timers` (a wholesale set with per-id validation). +async fn set_event_classes( + _auth: ControlAuth, + State(registry): State, + Path(event_id): Path, + Json(body): Json, +) -> Result, ProtocolError> { + let classes = registry.classes(); + for id in &body.ids { + if !classes.exists(id) { + return Err(ProtocolError::new( + ErrorCode::UnknownScope, + format!("no class with id {:?}", id.0), + )); + } + } + let meta = registry + .set_classes(&event_id, body.ids) + .map_err(registry_error_to_protocol)?; + Ok(Json(meta)) +} + +/// `PUT /events/{event_id}/classes/{class_id}/membership` — set which roster pilots race one +/// class (race redesign Slice 1a), RD-gated. +/// +/// [`ControlAuth`] runs first. The class must name a known directory class and **each** pilot id in +/// the body must name a known directory pilot (else a typed 404 naming the bad id) — so a class's +/// membership can never reference a deleted/unknown class or pilot. The event must exist (else a +/// 404). On success the updated [`EventMeta`] is returned. Mirrors `set_event_classes` / +/// `set_event_roster` (per-id validation against the relevant directory). +async fn set_class_membership( + _auth: ControlAuth, + State(registry): State, + Path((event_id, class_id)): Path<(EventId, ClassId)>, + Json(body): Json, +) -> Result, ProtocolError> { + if !registry.classes().exists(&class_id) { + return Err(ProtocolError::new( + ErrorCode::UnknownScope, + format!("no class with id {:?}", class_id.0), + )); + } + let pilots = registry.pilots(); + for slot in &body.pilots { + if !pilots.exists(&slot.pilot) { + return Err(ProtocolError::new( + ErrorCode::UnknownScope, + format!("no pilot with id {:?}", slot.pilot.0), + )); + } + } + // The event must exist (else a 404). Beyond directory existence, membership is scoped to **this + // event** (release-hardening P1-5): the class must be one the event *selected* and every pilot + // must be on the event's *roster* — otherwise a raw API call could seat a non-roster pilot or a + // class the event never picked, and the raced field would diverge from the seeding/standings + // (which resolve against the roster). Mirrors `validate_round_fields`'s class-selection guard. + let meta = registry.meta_of(&event_id).ok_or_else(|| { + ProtocolError::new( + ErrorCode::UnknownScope, + format!("no event with id {:?}", event_id.0), + ) + })?; + if !meta.classes.contains(&class_id) { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!("class {:?} is not selected by this event", class_id.0), + )); + } + for slot in &body.pilots { + if !meta.roster.contains(&slot.pilot) { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!("pilot {:?} is not on this event's roster", slot.pilot.0), + )); + } + } + // Validate each assigned channel (race redesign Slice 7a) against the event's **primary** + // timer's available-channels pool — the GQ-style fixed channel must be one the timer offers. + // The pool may exceed the timer's `node_count` (node_count caps only pilots-per-heat), so any + // channel in the pool is valid; we never cap the number of distinct channels at node_count. + let assigned: Vec = body.pilots.iter().filter_map(|s| s.channel).collect(); + if !assigned.is_empty() { + let timer = meta + .effective_primary() + .and_then(|id| registry.timers().get(&id)); + let Some(timer) = timer else { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + "cannot assign per-pilot channels: the event has no resolvable primary timer" + .to_string(), + )); + }; + for channel in &assigned { + if !timer.available_channels.contains(channel) { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "channel {channel} is not in the primary timer {:?}'s available channels", + timer.id.0 + ), + )); + } + } + } + let meta = registry + .set_class_membership(&event_id, class_id, body.pilots) + .map_err(registry_error_to_protocol)?; + Ok(Json(meta)) +} + +/// Map a [`RoundError`] to a [`ProtocolError`]: a missing event/round is a typed **404** +/// ([`ErrorCode::UnknownScope`]); an invalid round definition (bad class, unknown format, dangling +/// seeding source) is a **400** ([`ErrorCode::BadRequest`]). +fn round_error(e: RoundError) -> ProtocolError { + let code = match e { + RoundError::EventNotFound(_) | RoundError::RoundNotFound(_) => ErrorCode::UnknownScope, + RoundError::Invalid(_) => ErrorCode::BadRequest, + }; + ProtocolError::new(code, e.to_string()) +} + +/// `POST /events/{event_id}/rounds` — add a **round** to an event (race redesign Slice 2a), +/// RD-gated. +/// +/// [`ControlAuth`] runs first. The round id is **auto-generated** server-side (never in the body). +/// Each class in the body must be selected by the event, the `format` must be a known +/// [`FormatRegistry`](gridfpv_engine::format::FormatRegistry) name, and a `FromRanking` seeding +/// source must name an existing round — else a typed **400**. An unknown event is a **404**. On +/// success the created [`RoundDef`] (with its generated id) is returned and the event's meta is +/// written through to disk (issue #115). +async fn add_round( + _auth: ControlAuth, + State(registry): State, + Path(event_id): Path, + Json(body): Json, +) -> Result, ProtocolError> { + let round = registry.add_round(&event_id, body).map_err(round_error)?; + // Open-practice refinement: an open-practice round has no class/pilots, so the normal manual + // `FillRound` flow can't run — but its **channels are the lineup**, so the single open heat can + // be built immediately. Auto-run the equivalent of `FillRound` here so the RD can Stage/Start the + // practice with no manual fill. Idempotent for free: `fill_round` dedups against already-scheduled + // heats, so re-creating/editing never double-schedules — one open heat per round. A non-open + // round is left to the RD's manual `FillRound` as before. + if crate::round_engine::is_open_practice(&round) { + let state = resolve_event(®istry, &event_id)?; + let ack = crate::control_handler::apply_fill_round( + ®istry, + &event_id, + &state, + round.id.clone(), + // Open practice's single channel heat is one draw — single-step (#216). + crate::control::FillMode::Next, + ); + if let Some(err) = ack.error { + return Err(err); + } + } + Ok(Json(round)) +} + +/// `PUT /events/{event_id}/rounds/{round_id}` — replace an existing **round**'s fields (race +/// redesign Slice 2a), RD-gated. +/// +/// [`ControlAuth`] runs first. The round id is the path segment (not editable); every other field is +/// replaced wholesale. Same validation as `add_round` (bad class / format / seeding → **400**); an +/// unknown event or round id is a **404**. On success the updated [`RoundDef`] is returned and the +/// meta is written through to disk. +async fn update_round( + _auth: ControlAuth, + State(registry): State, + Path((event_id, round_id)): Path<(EventId, RoundId)>, + Json(body): Json, +) -> Result, ProtocolError> { + let round = registry + .update_round(&event_id, &round_id, body) + .map_err(round_error)?; + Ok(Json(round)) +} + +/// `DELETE /events/{event_id}/rounds/{round_id}` — remove a **round** from an event (race redesign +/// Slice 2a), RD-gated. +/// +/// [`ControlAuth`] runs first. An unknown event or round id is a typed **404**. On success the +/// event's updated [`EventMeta`] is returned and the meta is written through to disk. +async fn remove_round( + _auth: ControlAuth, + State(registry): State, + Path((event_id, round_id)): Path<(EventId, RoundId)>, +) -> Result, ProtocolError> { + let meta = registry + .remove_round(&event_id, &round_id) + .map_err(round_error)?; + Ok(Json(meta)) +} + +/// `GET /events/{event_id}/heats` — the event's **scheduled heats** (race redesign Slice 3b). +/// +/// A read (open, no token, like the snapshot routes): folds the event's log into one +/// [`HeatSummary`] per scheduled heat — id, lineup, the round/class it was tagged with, its +/// derived phase, and whether it is the current heat — in first-scheduled order. The Heats UI +/// groups this by round to render each round's heats list. An unknown event is a typed **404**. +async fn list_heats( + State(registry): State, + Path(event_id): Path, +) -> Result>, ProtocolError> { + let state = resolve_event(®istry, &event_id)?; + let (events, _cursor) = state.read()?; + Ok(Json(heat_summaries(&events))) +} + +/// Map a [`round_engine::FillError`] to a typed [`ProtocolError`]: an unknown round (or seeding +/// source round) is a **404** ([`ErrorCode::UnknownScope`]); an unscorable round (empty field, +/// unknown format) is a **400** ([`ErrorCode::BadRequest`]) — mirroring the control handler's +/// `FillRound` mapping so the read routes answer the same shape. +fn fill_error(err: round_engine::FillError) -> ProtocolError { + use round_engine::FillError; + let code = match err { + FillError::UnknownRound(_) | FillError::UnknownSourceRound(_) => ErrorCode::UnknownScope, + FillError::EmptyField(_) + | FillError::UnknownFormat(_) + | FillError::MissingChannel(_) + | FillError::SeedingTooDeep => ErrorCode::BadRequest, + }; + ProtocolError::new(code, err.to_string()) +} + +/// `GET /events/{event_id}/rounds/{round_id}/ranking` — a round's **ranking** (race redesign +/// Slice 5/6a). +/// +/// The ordered per-pilot ranking the engine seeds `FromRanking` from ([`round_engine::round_ranking`]) +/// — the same provisional-or-final ordering a bracket carries — exposed as a read for the +/// bracket-carry display. A read (open, no token, like the snapshot routes). An unknown event or +/// round is a typed **404**; a round that cannot be ranked (unknown format, dangling seeding +/// source) is a **400**. +async fn round_ranking( + State(registry): State, + Path((event_id, round_id)): Path<(EventId, RoundId)>, +) -> Result>, ProtocolError> { + let state = resolve_event(®istry, &event_id)?; + let meta = registry.meta_of(&event_id).ok_or_else(|| { + ProtocolError::new( + ErrorCode::UnknownScope, + format!("no event with id {:?}", event_id.0), + ) + })?; + let round = meta + .rounds + .iter() + .find(|r| r.id == round_id) + .ok_or_else(|| { + ProtocolError::new( + ErrorCode::UnknownScope, + format!("no round with id {:?} in this event", round_id.0), + ) + })?; + let (events, _cursor) = state.read()?; + let ranking = round_engine::round_ranking(&meta, round, &events).map_err(fill_error)?; + Ok(Json(ranking)) +} + +/// `GET /events/{event_id}/rounds/{round_id}/standings` — a round's **standings** (time-trial / qual +/// display). +/// +/// The per-pilot rows for a single round ([`round_engine::round_standings`]): each pilot's best +/// single lap plus the win-condition metric they're ranked on (best-N-consecutive time, lap count, +/// or best lap), in [`round_engine::round_ranking`] order so the standings + ranking never disagree. +/// A read (open, no token, like the ranking route). An unknown event or round is a typed **404**; a +/// round that cannot be scored (unknown format, dangling seeding source) is a **400**. +async fn round_standings( + State(registry): State, + Path((event_id, round_id)): Path<(EventId, RoundId)>, +) -> Result>, ProtocolError> { + let state = resolve_event(®istry, &event_id)?; + let meta = registry.meta_of(&event_id).ok_or_else(|| { + ProtocolError::new( + ErrorCode::UnknownScope, + format!("no event with id {:?}", event_id.0), + ) + })?; + let round = meta + .rounds + .iter() + .find(|r| r.id == round_id) + .ok_or_else(|| { + ProtocolError::new( + ErrorCode::UnknownScope, + format!("no round with id {:?} in this event", round_id.0), + ) + })?; + let (events, _cursor) = state.read()?; + let standings = round_engine::round_standings(&meta, round, &events).map_err(fill_error)?; + Ok(Json(standings)) +} + +/// `GET /events/{event_id}/classes/{class_id}/standings` — a class's **standings** (race redesign +/// Slice 5/6a). +/// +/// The season-join projection the Results UI reads: [`round_engine::class_standings`] folds the +/// event log + meta into one per-pilot row per competitor that raced the class, aggregated across +/// the class's rounds (points, best lap, total laps), best standing first. A read (open, no token). +/// An unknown event is a typed **404**; an unscorable class round is a **400**. A class with no +/// rounds yields empty standings (a 200), not an error. +async fn class_standings( + State(registry): State, + Path((event_id, class_id)): Path<(EventId, ClassId)>, +) -> Result, ProtocolError> { + let state = resolve_event(®istry, &event_id)?; + let meta = registry.meta_of(&event_id).ok_or_else(|| { + ProtocolError::new( + ErrorCode::UnknownScope, + format!("no event with id {:?}", event_id.0), + ) + })?; + let (events, _cursor) = state.read()?; + let standings = round_engine::class_standings(&meta, &class_id, &events).map_err(fill_error)?; + Ok(Json(standings)) +} + +/// One row of the **event-wide** audit trail (`GET /events/{event_id}/audit`): a per-heat +/// marshaling [`AuditEntry`] plus the heat it belongs to. +/// +/// The per-heat [`AuditEntry`] deliberately carries no heat id — it is served from a heat-scoped +/// route where the heat is implicit. The event-wide read merges every heat's trail into one list, +/// so each entry must say *which* heat it rules on; the console's Audit page renders (and filters +/// by) that tag. The entry's own fields are flattened onto the row, so on the wire this is "an +/// `AuditEntry` plus `heat`" — additive, no re-modelling. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, Deserialize, ts_rs::TS)] +#[ts(export, export_to = "bindings/")] +pub struct EventAuditEntry { + /// The heat this ruling belongs to — attributed by the same heat-window rules Marshaling's + /// per-heat audit uses ([`heat_window_offsets`]), so the two views can never disagree. + pub heat: HeatId, + /// The per-heat audit fact itself (kind / when / offset / competitor / summary), flattened. + #[serde(flatten)] + pub entry: AuditEntry, +} + +/// Fold the whole event log into the **event-wide audit trail**, newest first. +/// +/// For each heat the log ever scheduled (the distinct `HeatScheduled` ids, in order), this runs +/// the *existing* per-heat audit fold — [`marshaling_log`] over that heat's +/// [`heat_window_offsets`], exactly the pipeline the heat-scoped `?projection=audit` snapshot +/// uses — tags each entry with the heat id, merges all heats, and sorts by the entry's global +/// append offset **descending** (append order is time order, so newest first). Reusing the +/// shipped window attribution is the point: a ruling filed about a *finished* heat while a later +/// heat is live lands under the marshaled heat here too, and a **Restarted** heat's pre-restart +/// rulings are absent (the window folds from the heat's current run by design — an abandoned +/// run's rulings are not part of the heat's result, so they are not part of its audit either). +pub(crate) fn event_audit_log(stored: &[StoredEvent]) -> Vec { + let events: Vec = stored.iter().map(|s| s.event.clone()).collect(); + // The distinct heats ever scheduled, in first-scheduled order (a re-schedule of the same id + // must not fold — and double-report — the same window twice). + let mut heats: Vec = Vec::new(); + for event in &events { + if let Event::HeatScheduled { heat, .. } = event { + if !heats.contains(heat) { + heats.push(heat.clone()); + } + } + } + let mut entries: Vec = Vec::new(); + for heat in &heats { + let offsets = heat_window_offsets(&events, heat); + let trail = marshaling_log( + offsets + .iter() + .map(|(o, e)| (stored.get(*o as usize).and_then(|s| s.recorded_at), *o, e)), + heat, + ); + entries.extend(trail.into_iter().map(|entry| EventAuditEntry { + heat: heat.clone(), + entry, + })); + } + // Newest first across the whole event: the global append offset is the one total order every + // heat's entries share (recorded_at can be absent), so descending offset is descending time. + entries.sort_by_key(|e| std::cmp::Reverse(e.entry.at_ref)); + entries +} + +/// `GET /events/{event_id}/audit` — the **event-wide** audit trail (the "defensible results" +/// review surface). +/// +/// Serves every heat's marshaling audit fold, heat-tagged and merged newest-first (see +/// [`event_audit_log`]). This is what the console's Audit page reads: the full searchable ruling +/// history for the event, while Marshaling keeps only the marshaled heat's recent entries. An +/// open read (no token), like the heats list and the snapshot routes; an unknown event is a +/// typed **404**. +async fn event_audit( + State(registry): State, + Path(event_id): Path, +) -> Result>, ProtocolError> { + let state = resolve_event(®istry, &event_id)?; + let (stored, _cursor) = state.read_stored()?; + Ok(Json(event_audit_log(&stored))) +} + +/// `POST /events/{event_id}/auth/join-token` — mint a fresh **read-only** join token +/// (protocol.html §5, §9.4) — issue #63, now event-rooted. +/// +/// [`ControlAuth`] runs first: only an authenticated **RD** may mint one. The token store is +/// Director-wide (shared across events), so the minted token reads any event; the path is +/// rooted under an event for a uniform surface and a typed 404 on an unknown event id. On +/// success a brand-new read-only token is returned as a [`JoinTokenResponse`]. +async fn mint_join_token( + _auth: ControlAuth, + State(registry): State, + Path(event_id): Path, +) -> Result, ProtocolError> { + let state = resolve_event(®istry, &event_id)?; + let token = state.tokens().issue_join_token(); + Ok(Json(JoinTokenResponse { token })) +} + +/// Whether a request path addresses a known protocol **API tree** (#64). +/// +/// The blanket SPA fallback (in the Director wiring) serves `index.html` for any unmatched +/// path so client-side routes resolve. That is wrong for a *mistyped API* path — a bad +/// `/snapshot/...`, `/control/...`, `/stream/...`, `/health/...`, or `/auth/...` — which +/// should surface a typed [`ProtocolError`], not an HTML 200 the client cannot parse as a +/// `Snapshot`. This predicate names the API prefixes so the [`smart_fallback`] can split +/// "mistyped API → 404 JSON" from "genuine client-side route → SPA shell". +/// +/// A path matches when it equals a prefix exactly or continues with `/` (so `/snapshotxyz` +/// is *not* an API path, but `/snapshot`, `/snapshot/`, and `/snapshot/zzz` all are). +pub fn is_api_path(path: &str) -> bool { + // The event-rooted surface (#72) puts snapshot/stream/control/auth *under* `/events`, so + // `/events` is the one API tree that matters now; the bare `/snapshot|/stream|/control|/auth` + // prefixes are kept so a *legacy* (pre-#72) mistyped call still 404s as a typed API error + // rather than falling through to the SPA shell. + const API_PREFIXES: [&str; 10] = [ + "/health", + "/events", + "/active-event", + "/timers", + "/pilots", + "/classes", + "/snapshot", + "/stream", + "/control", + "/auth", + ]; + API_PREFIXES.iter().any(|prefix| { + path == *prefix + || path + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/')) + }) +} + +/// The typed-404 fallback for **unmatched API-tree** paths (#64). +/// +/// Mounted as [`router`]'s own fallback so a request under a known API prefix that matched +/// no concrete route (a wrong `/snapshot/zzz/...`, a `/control/bogus`, a `/auth/nope`) +/// returns a [`ProtocolError`] of [`ErrorCode::UnknownScope`] as JSON (HTTP 404) — the one +/// uniform error shape — rather than falling through to the SPA shell. Non-API paths never +/// reach this fallback when the SPA service is composed via [`smart_fallback`]; reached +/// directly (a bare `router` with no SPA) every unmatched path is a typed 404, which is the +/// correct API-only behaviour. +async fn api_fallback(req: Request) -> ProtocolError { + ProtocolError::new( + ErrorCode::UnknownScope, + format!( + "no protocol route matches {} {}", + req.method(), + req.uri().path() + ), + ) +} + +/// Compose the Director's outer fallback (#64): a **mistyped API path → typed 404 JSON**, +/// **any other path → the SPA `spa` service** (the client-side router shell). +/// +/// The Director mounts the protocol [`router`] first, then needs *one* fallback for +/// everything it does not own. A naive `fallback_service(spa)` serves `index.html` for a +/// mistyped API path too — so a wrong `/snapshot/...` arrives as an HTML 200 the client +/// cannot parse (v0.4 bug #64). This wraps the SPA service so a path under a known API tree +/// ([`is_api_path`]) instead gets the typed [`ProtocolError`] 404 from [`api_fallback`], +/// while genuine client-side routes still resolve to the SPA shell exactly as before. +/// +/// Generic over the inner SPA service so the Director's `ServeDir(+ index.html fallback)` +/// drops straight in (its `Response` is mapped into the axum +/// [`Response`]); the returned service is itself usable with [`Router::fallback_service`]. +pub fn smart_fallback(spa: S) -> Router +where + S: tower::Service, Response = Response, Error = std::convert::Infallible> + + Clone + + Send + + Sync + + 'static, + S::Future: Send + 'static, + B: axum::body::HttpBody + Send + 'static, + B::Error: Into, +{ + Router::new() + // Unmatched API-tree paths → typed 404 (#64). A `Router` with no routes matches + // nothing, so *every* request hits this fallback; we branch on the path there. + .fallback(move |req: Request| { + let mut spa = spa.clone(); + async move { + if is_api_path(req.uri().path()) { + api_fallback(req).await.into_response() + } else { + // Drive the SPA service for a genuine client-side route, mapping its + // body into the axum `Body` the fallback returns. + use tower::ServiceExt; + match spa.ready().await { + Ok(svc) => match svc.call(req).await { + Ok(response) => response.map(Body::new), + Err(infallible) => match infallible {}, + }, + Err(infallible) => match infallible {}, + } + } + } + }) +} + +/// 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 marshaling [`AuditEntry`](gridfpv_projection::AuditEntry) trail (#55). + Audit, + /// The heat's scored [`HeatResult`]. + Result, + /// The heat's captured RSSI signal trace + /// ([`SignalTraceView`](gridfpv_projection::SignalTraceView), marshaling Slice 1). + Signal, +} + +/// Query parameters for the heat-scope endpoint. +#[derive(Debug, Default, Deserialize)] +struct HeatQuery { + #[serde(default)] + projection: HeatProjection, +} + +/// `GET /events/{event_id}/snapshot/event/{event}` — the whole event's live race-state +/// (§4 event scope), served against the resolved event's own log (issue #72). +/// +/// `event_id` resolves to that event's [`AppState`] via the registry (an unknown id → a +/// typed 404). The trailing `{event}` is the §4 scope address within the event; with the +/// log now genuinely per-event, the event-scope body folds **that event's** log — no more +/// whole-log passthrough. +async fn snapshot_event( + State(registry): State, + Path((event_id, _event)): Path<(EventId, EventId)>, +) -> Result, ProtocolError> { + let state = resolve_event(®istry, &event_id)?; + let (stored, cursor) = state.read_stored()?; + let events: Vec = stored.iter().map(|s| s.event.clone()).collect(); + // Open-practice overlay (open-practice format, Slice 1): the live state's phase/clock are always + // the **real log's** (folded here as for any heat); while an open-practice heat is active its + // per-channel laps are in memory (NOT logged), so the accumulator splices those laps onto the log + // base — "snapshot first, then subscribe" stays correct (the `/stream` fold applies the same + // merge), so a client renders the live per-channel laps immediately atop a truthful phase/clock. + // `with_heat_timing` folds the current heat's server-authoritative race-start/end instants + // (#62 follow-up) from the stored log's `recorded_at` so the clock is consistent everywhere. + let body = state + .open_practice() + .merge_into(with_heat_timing(live_state(&events), &stored)); + Ok(Json(Snapshot { + cursor, + body: ProjectionBody::LiveRaceState(body), + })) +} + +/// `GET /snapshot/class/{event}/{class}` — a class's live race-state (§4 class scope). +/// +/// Now that [`Event::HeatScheduled`] carries the `class` it runs in (race redesign Slice 5/6a), +/// the class scope is a **real filter**: [`class_window`] scopes the log to the heats tagged with +/// `class` (their scheduling / state-change events plus the passes that fall while one of them is +/// the active heat), and the live state is folded over that filtered view — so the body reflects +/// only this class's racing, not the whole event. A class with no tagged heats folds an idle state. +async fn snapshot_class( + State(registry): State, + Path((event_id, _event, class)): Path<(EventId, EventId, ClassId)>, +) -> Result, ProtocolError> { + let state = resolve_event(®istry, &event_id)?; + let (stored, cursor) = state.read_stored()?; + let events: Vec = stored.iter().map(|s| s.event.clone()).collect(); + let class_offsets = class_window_offsets(&events, &class); + // The window's `current_heat` resolves which heat is on the timer; its timing is folded + // from the *full* stored log (the heat's transition instants live there with `recorded_at`). + Ok(Json(Snapshot { + cursor, + body: ProjectionBody::LiveRaceState(with_heat_timing( + live_state_over(&class_offsets), + &stored, + )), + })) +} + +/// Filter the log to a single **class's** heats (race redesign Slice 5/6a): every event that +/// belongs to a heat tagged `HeatScheduled { class: Some(class), .. }`, plus the passes and +/// marshaling adjudications that fall *while one of the class's heats is the active one*. +/// +/// The class's heats are first collected from the `HeatScheduled` tags; then the log is replayed +/// once, opening the window on any heat-loop event for one of those heats and closing it on a +/// heat-loop event for a heat *not* in the class — the same position-based pass attribution +/// [`heat_window_offsets`] uses to scope a single heat, generalized to a set of heats. So a +/// class's live state folds only its own heats and passes, with no other class's racing bleeding +/// in. Carries each event's GLOBAL append offset — the class-scope live fold +/// feeds these to [`live_state_over`] so marshaling adjudications (global `LogRef` targets) +/// resolve inside the filtered view (the same #55 rule as `heat_window_offsets`). +pub(crate) fn class_window_offsets(events: &[Event], class: &ClassId) -> Vec<(u64, Event)> { + // The heat ids tagged with this class (a `HeatScheduled` whose `class` equals `class`). + let class_heats: std::collections::HashSet<&HeatId> = events + .iter() + .filter_map(|e| match e { + Event::HeatScheduled { + heat, + class: Some(c), + .. + } if c == class => Some(heat), + _ => None, + }) + .collect(); + + let mut window = Vec::new(); + // `active` tracks whether the cursor is currently inside one of the class's heats: it opens on + // a heat-loop event for a class heat and closes on a heat-loop event for any non-class heat. + let mut active = false; + for (offset, event) in events.iter().enumerate() { + let offset = offset as u64; + match event { + Event::HeatScheduled { heat, .. } | Event::HeatStateChanged { heat, .. } => { + active = class_heats.contains(heat); + if active { + window.push((offset, event.clone())); + } + } + // A tagged pass belongs to its stamped heat — in or out by class membership, + // independent of the positional cursor (same rule as `heat_window_offsets`), + // and frozen out once that heat's run is official (the Final freeze). + Event::Pass(p) if p.heat.is_some() => { + if p.heat.as_ref().is_some_and(|h| { + class_heats.contains(h) + && offset < crate::live_state::current_run_pass_ceiling(events, h) as u64 + }) { + window.push((offset, event.clone())); + } + } + // Untagged passes and adjudications belong to whichever heat is currently active. + _ if active => window.push((offset, event.clone())), + _ => {} + } + } + window +} + +/// `GET /snapshot/heat/{heat}` — the tightest scope (§4 heat scope). +/// +/// `?projection=live` (default) returns the heat's [`LiveRaceState`]; `?projection=laps` +/// its [`LapList`]; `?projection=audit` its marshaling audit trail +/// ([`AuditEntry`](gridfpv_projection::AuditEntry) list, #55); `?projection=result` its scored +/// [`HeatResult`]; `?projection=signal` its captured RSSI signal trace +/// ([`SignalTraceView`](gridfpv_projection::SignalTraceView), marshaling Slice 1). The log is +/// filtered to the heat's window so the body is heat-local. +async fn snapshot_heat( + State(registry): State, + Path((event_id, heat)): Path<(EventId, HeatId)>, + Query(query): Query, +) -> Result, ProtocolError> { + let state = resolve_event(®istry, &event_id)?; + let (stored, cursor) = state.read_stored()?; + let events: Vec = stored.iter().map(|s| s.event.clone()).collect(); + + // 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), + )); + } + + // The heat's window, carrying each event's GLOBAL append offset — load-bearing for the + // marshaling lap/audit folds (their `LogRef`s must be global, not window-relative, #55). + let heat_offsets = heat_window_offsets(&events, &heat); + let heat_events: Vec = heat_offsets.iter().map(|(_, e)| e.clone()).collect(); + + // The heat's ROUND config, resolved once for every projection that scores or folds laps: + // the win condition (#45) and the min-lap floor (D26 — the floor must reach the laps, + // live, and result folds identically, or the lap list and the score disagree about a + // suppressed pass). A heat with no round (ad-hoc) keeps the neutral defaults. + let round_def = events + .iter() + .find_map(|e| match e { + Event::HeatScheduled { + heat: h, + round: Some(round), + .. + } if *h == heat => Some(round.clone()), + _ => None, + }) + .and_then(|round_id| { + registry + .meta_of(&event_id) + .and_then(|meta| meta.rounds.iter().find(|r| r.id == round_id).cloned()) + }); + let min_lap_micros = min_lap_micros_of(round_def.as_ref()); + + let body = match query.projection { + HeatProjection::Live => { + // Open-practice overlay (open-practice format, Slice 1): fold the heat's real log window + // for a truthful phase/clock, then — when this *is* the active open-practice heat — splice + // its in-memory (NOT logged) per-channel laps on top. `merge_into` guards on the heat + // matching the accumulator's, so a non-op heat folds its log window unchanged. + ProjectionBody::LiveRaceState(state.open_practice().merge_into(with_heat_timing( + live_state_over_with_floor(&heat_offsets, min_lap_micros), + &stored, + ))) + } + HeatProjection::Laps => ProjectionBody::LapList(lap_list_marshaled_with_floor( + heat_offsets.iter().map(|(o, e)| (*o, e)), + min_lap_micros, + )), + HeatProjection::Audit => { + // The defensible-results audit panel: fold the heat's rulings into a reverse-chrono + // trail, keyed on global offsets with each fact's `recorded_at` as "when" (#55). + ProjectionBody::MarshalingAudit(marshaling_log( + heat_offsets + .iter() + .map(|(o, e)| (stored.get(*o as usize).and_then(|s| s.recorded_at), *o, e)), + &heat, + )) + } + HeatProjection::Result => { + // Score under the heat's ROUND win condition (#45), mirroring + // `round_engine::completed_heats`: resolve the heat's round from its + // `HeatScheduled` tag, then look its `RoundDef::win_condition` up in the event + // meta. A heat with no associated round (an ad-hoc / open-practice heat) falls + // back to a neutral best-lap qualifying rule, so an un-tagged heat is unchanged. + let win_condition = round_def + .as_ref() + .map(|r| r.win_condition) + .unwrap_or(WinCondition::BestLap); + // Score over the heat's FULL adjudicated window via the one shared helper the + // round/class standings also use ([`round_engine::completed_heats`] → + // [`score_heat_window`]), so the per-heat result and the standings can never + // disagree on an adjudicated heat (#226). The helper preserves the window's global + // offsets so a `RulingReversed` / `LapThrownOut` resolves to its true `LogRef` (#55). + ProjectionBody::HeatResult(score_heat_window( + &events, + &heat, + win_condition, + min_lap_micros, + )) + } + HeatProjection::Signal => { + // The signal-as-evidence trace (marshaling Slice 1): fold the heat window's + // SignalChunk/SignalThresholds facts into a per-competitor trace. Pure and clock-free. + ProjectionBody::SignalTrace(signal_trace(&heat_events)) + } + }; + + Ok(Json(Snapshot { cursor, body })) +} + +/// `GET /snapshot/pilot/{event}/{pilot}` — a pilot's laps across the event (§4 pilot scope). +/// +/// Resolves the `PilotId` to the source competitor(s) it is **bound** to by the registration +/// projection ([`registrations`], #60), then filters the lap projection to those +/// competitors — so a pilot following their own laps sees every channel they were +/// registered on (e.g. across re-seats / multiple timers). For an event with no registration +/// bindings yet, it falls back to the legacy behaviour of treating the `pilot` id as a bare +/// [`CompetitorRef`], so an un-registered setup still resolves a pilot by their source ref. +async fn snapshot_pilot( + State(registry): State, + Path((event_id, _event, pilot)): Path<(EventId, EventId, PilotId)>, +) -> Result, ProtocolError> { + let state = resolve_event(®istry, &event_id)?; + let (events, cursor) = state.read()?; + + // The source competitors bound to this pilot (by the registration fold). When the log + // carries no binding for the pilot, fall back to matching the pilot id against a bare + // competitor ref (the pre-#60 behaviour) so unregistered events still resolve. + let bindings = registrations(&events); + let bound: Vec = bindings + .iter() + .filter(|(_, bound_pilot)| **bound_pilot == pilot) + .map(|(key, _)| key.competitor.clone()) + .collect(); + let fallback_ref = CompetitorRef(pilot.0.clone()); + + // The pilot view folds the WHOLE log (every heat, every round) — rounds carry different + // min-lap floors, so no single floor applies here; the per-heat views are the floored, + // authoritative surfaces (D26). A pilot may therefore see a raw echo here that the RD's + // heat view suppresses — read-only, never scored. + let full = lap_list_marshaled(events.iter().enumerate().map(|(i, e)| (i as u64, e))); + let competitors: Vec<_> = full + .competitors + .into_iter() + .filter(|c| { + if bound.is_empty() { + c.competitor.competitor == fallback_ref + } else { + bound.contains(&c.competitor.competitor) + } + }) + .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 }), + })) +} + +/// Current server wall-clock time in **microseconds** since the Unix epoch — the basis the +/// race clock is anchored to. Used to stamp a heat transition's `recorded_at` at append time +/// (the `Running` / `Unofficial` instants the live `race_started_at` / `race_ended_at` fold +/// from), so header and HUD count from the one authoritative race-go. +fn now_micros() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_micros() as i64) + .unwrap_or(0) +} + +/// Filter the log to a single heat's window, **preserving each event's global append offset**. +/// +/// Three routing rules, by what the event itself can say about where it belongs: +/// +/// - **Heat-tagged marshaling events** (`PenaltyApplied` / `HeatVoided` / `ProtestFiled` / +/// a tagged `LapInserted`) route **by their tag** — a ruling about a finished heat filed +/// while a later heat is live must land in the marshaled heat's window and never leak into +/// the live one. Positional attribution here was the mis-windowing bug: the ruling was +/// silently a no-op on its target heat AND disqualified/penalized pilots in whichever heat +/// happened to be active. +/// - **Target-carrying rulings** (`DetectionVoided` / `LapAdjusted` / `LapSplit` / +/// `LapThrownOut` / `ProtestResolved` / `RulingReversed`) belong to whichever heat their +/// **target offset** is in. Targets always reference an earlier append offset, so one +/// forward scan with an incrementally-built membership set resolves chains ("reverse the +/// ruling that voided the pass…") without a fixpoint pass. +/// - **Untagged events** (raw `Pass`es — wire observations that carry no heat id — plus a +/// legacy untagged `LapInserted`, registrations, …) attribute **positionally**: they belong +/// to whichever heat is active at that point in the log, the same ordering the engine uses +/// to decide which heat consumes a pass (race-engine.html §2). +/// +/// The retained **global offset** is load-bearing for marshaling (#55): the lap projection and +/// audit fold are keyed on it, and a `LogRef` correction command targets that global offset. An +/// earlier bug re-enumerated the window `0,1,2,…`, so a UI-selected lap in a later heat targeted +/// the *wrong* pass; folding with the real offsets fixes that. +/// The window additionally folds from the heat's **current run** +/// ([`current_run_start`](crate::live_state::current_run_start) — the latest `Running`, or one +/// past the latest `Aborted`/`Restarted`): everything except the heat-loop events themselves +/// must sit at/after that boundary. A reset abandons the prior run, so its passes — and any +/// rulings made about them — are not part of this heat's result, the same rule the live +/// standings already applied. Without it, a Restarted-and-re-raced heat scored BOTH runs' +/// passes (the ghost run even out-ranked the real one). Heat-loop events stay un-filtered: +/// they carry the lineup and the FSM lineage the folds need. +pub(crate) fn heat_window_offsets(events: &[Event], heat: &HeatId) -> Vec<(u64, Event)> { + let run_start = crate::live_state::current_run_start(events, heat) as u64; + let pass_ceiling = crate::live_state::current_run_pass_ceiling(events, heat) as u64; + let mut window = Vec::new(); + // The offsets already claimed by this window — a target-carrying ruling joins iff its + // target is one of them (targets always point backwards, so one forward scan suffices). + let mut claimed: BTreeSet = BTreeSet::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 (offset, event) in events.iter().enumerate() { + let offset = offset as u64; + let include = match event { + Event::HeatScheduled { heat: h, .. } | Event::HeatStateChanged { heat: h, .. } => { + active = h == heat; + active + } + // Heat-tagged marshaling events: by tag, never by position. + Event::HeatVoided { heat: h } + | Event::PenaltyApplied { heat: h, .. } + | Event::ProtestFiled { heat: h, .. } => h == heat && offset >= run_start, + Event::LapInserted { heat: Some(h), .. } => h == heat && offset >= run_start, + // Target-carrying rulings: by their target's membership (a ruling targeting an + // abandoned run's pass drops out with its target). + Event::DetectionVoided { target } + | Event::LapAdjusted { target, .. } + | Event::LapSplit { target, .. } + | Event::LapThrownOut { target } + | Event::ProtestResolved { target, .. } + | Event::RulingReversed { target } => { + claimed.contains(&target.0) && offset >= run_start + } + // Passes: by their bridge-stamped heat TAG when present (robust against a + // heat-span event closing the positional span mid-race — the scheduling-eats-laps + // bug); an untagged (legacy) pass keeps the positional rule. Either way, a pass + // landing at/after the run's `Finalized` is FROZEN OUT: once a result is official + // it cannot shift under a delayed catch-up pass with no command and no audit + // entry (rulings are unaffected — a Revert re-opens marshaling, not the record). + Event::Pass(p) => { + let before_official = offset < pass_ceiling; + match &p.heat { + Some(h) => h == heat && offset >= run_start && before_official, + None => active && offset >= run_start && before_official, + } + } + // Untagged (legacy insertions, registrations, …): positional. + _ => active && offset >= run_start, + }; + if include { + claimed.insert(offset); + window.push((offset, event.clone())); + } + } + window +} + +/// Score a single heat over its **full adjudicated event window** under `win_condition` — the +/// one scoring path shared by the per-heat result projection ([`HeatProjection::Result`]) and +/// the round / class standings ([`round_engine::completed_heats`]), so the heat page and the +/// standings can never disagree on an adjudicated heat (#226). +/// +/// Before this existed, the standings path scored each heat over a **pass-only** list (every +/// marshaling adjudication — DQ / time / throw-out / void / lap-edit — discarded), so a heat's +/// standings showed the raw on-track result while its heat page showed the adjudicated one. This +/// closes that split-brain by giving both call sites the *same* window + scorer. +/// +/// Windows the log to the heat via [`heat_window_offsets`] (the heat's passes **and** every +/// adjudication that falls while the heat is active), **preserving each event's global append +/// offset** so a [`RulingReversed`](gridfpv_events::Event::RulingReversed) / +/// [`LapThrownOut`](gridfpv_events::Event::LapThrownOut) resolves to its true `LogRef` target — +/// a re-enumerated window would match the wrong offset (#55). The race clock is the window's +/// earliest lap-gate pass. Scores via [`score_corrected_with_global_offsets`] under the round's win +/// condition, so penalties / throw-outs / voids / lap-edits all land. +pub(crate) fn score_heat_window( + events: &[Event], + heat: &HeatId, + win_condition: WinCondition, + min_lap_micros: Option, +) -> HeatResult { + let heat_offsets = heat_window_offsets(events, heat); + // Fold the marshaling lap corrections (void / insert / adjust / split) into the pass + // stream FIRST — scoring raw passes here was the residual #226 split-brain: the marshaling + // lap list showed the corrected laps while the result, rankings, standings, and seeding + // scored the uncorrected ones. The corrected stream keeps each surviving pass's global + // offset, so a throw-out targeting a lap's end pass still excludes the right lap. The + // round's min-lap floor (D26) applies here too — the score and the lap list must agree. + let corrected = gridfpv_projection::corrected_passes_with_floor( + heat_offsets.iter().map(|(o, e)| (*o, e)), + min_lap_micros, + ); + let race_start = corrected + .iter() + .filter(|(_, p)| p.gate.is_lap_gate()) + .map(|(_, p)| p.at) + .min() + .unwrap_or(SourceTime::from_micros(0)); + score_corrected_with_global_offsets( + &corrected, + win_condition, + race_start, + heat_offsets.iter().map(|(o, e)| (*o, e)), + ) +} + +/// A round's min-lap floor in MICROSECONDS (D26), `None` when unset/zero — the single +/// conversion every fold call site shares. +pub(crate) fn min_lap_micros_of(round: Option<&crate::events::RoundDef>) -> Option { + round + .and_then(|r| r.min_lap_secs) + .filter(|s| *s > 0) + .map(|s| s as i64 * 1_000_000) +} + +/// 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 { + // `Body` and `Request` come in via `use super::*` (they are `use`d at module level for + // the smart fallback); the tests reach them through the glob. + use super::*; + use crate::events::{MemberSlot, NewRoundReq, SeedingRule}; + use gridfpv_engine::scoring::Metric; + use gridfpv_events::{AdapterId, GateIndex, HeatTransition, LogRef, Pass}; + use gridfpv_projection::CompetitorKey; + 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, + heat: None, + }) + } + + /// A recorded heat log: q-1 scheduled, run through to Final, 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())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + 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::Finalized, + }, + ] + } + + /// D26: the min-lap floor reaches SCORING through `score_heat_window` — an echo pass + /// that closes an under-floor lap is suppressed from the scored chain, so the result and + /// the (floored) lap list agree: the 0.004s phantom can never be anyone's best lap. + #[test] + fn score_heat_window_applies_the_min_lap_floor() { + let mut events = recorded_heat(); // A: passes at 1.0s / 4.0s / 6.5s (laps 3.0s, 2.5s) + // A double-detection echo 4ms after A's second pass — inserted DURING the run + // (appending it after `Finalized` would hit the Final freeze instead, which this + // test's own control run would then be measuring). + let finished_at = events + .iter() + .position(|e| { + matches!( + e, + Event::HeatStateChanged { + transition: HeatTransition::Finished, + .. + } + ) + }) + .unwrap(); + events.insert(finished_at, pass("A", 4_004_000, 9)); + let heat = HeatId("q-1".into()); + + let unfloored = score_heat_window(&events, &heat, WinCondition::BestLap, None); + let a = unfloored + .places + .iter() + .find(|p| p.competitor.competitor.0 == "A") + .unwrap(); + assert_eq!( + a.metric, + gridfpv_engine::scoring::Metric::BestLapMicros(Some(4_000)), + "without the floor the echo IS the (phantom) best lap" + ); + + let floored = score_heat_window(&events, &heat, WinCondition::BestLap, Some(1_000_000)); + let a = floored + .places + .iter() + .find(|p| p.competitor.competitor.0 == "A") + .unwrap(); + assert_eq!( + a.metric, + gridfpv_engine::scoring::Metric::BestLapMicros(Some(2_500_000)), + "the floor suppresses the echo; the real 2.5s lap wins" + ); + } + + /// The FINAL FREEZE: a pass landing AFTER a heat's run went official never joins its + /// window — a delayed RotorHazard catch-up pass (tagged or untagged) used to silently + /// change a Final result with no command, no ruling, and no audit entry. + #[test] + fn a_pass_landing_after_finalized_never_joins_the_official_record() { + let heat = HeatId("q-1".into()); + let mut events = recorded_heat(); + let window_before = heat_window_offsets(&events, &heat); + let passes_before = window_before + .iter() + .filter(|(_, e)| matches!(e, Event::Pass(_))) + .count(); + assert_eq!(passes_before, 5, "the run's real passes all count"); + + // A late catch-up pass TAGGED with the (now Final) heat… + let mut late = Pass { + adapter: AdapterId("vd".into()), + competitor: CompetitorRef("A".into()), + at: SourceTime::from_micros(9_000_000), + sequence: Some(9), + gate: GateIndex::LAP, + signal: None, + heat: Some(heat.clone()), + }; + events.push(Event::Pass(late.clone())); + // …and an UNTAGGED one (legacy positional attribution would claim it too). + late.heat = None; + late.sequence = Some(10); + events.push(Event::Pass(late)); + + let window_after = heat_window_offsets(&events, &heat); + let passes_after = window_after + .iter() + .filter(|(_, e)| matches!(e, Event::Pass(_))) + .count(); + assert_eq!( + passes_after, passes_before, + "the official record is frozen — late passes stay out" + ); + + // Rulings are NOT frozen (Revert re-opens marshaling): a void targeting a real run + // pass still joins the window. + events.push(Event::DetectionVoided { target: LogRef(6) }); + let with_ruling = heat_window_offsets(&events, &heat); + assert!( + with_ruling + .iter() + .any(|(_, e)| matches!(e, Event::DetectionVoided { .. })), + "rulings keep flowing into the window" + ); + } + + /// A registry whose Practice event carries a round with `win_condition` and a heat `q-1` + /// tagged with that round, driven Scheduled → Final over the given lap-gate `passes`. Used to + /// prove the result projection scores under the heat's round win condition (#45). + fn registry_with_round_heat( + win_condition: WinCondition, + time_limit_secs: Option, + passes: Vec, + ) -> EventRegistry { + let registry = EventRegistry::new(None).unwrap(); + let event_id = EventId(PRACTICE_EVENT_ID.into()); + let round = registry + .add_round( + &event_id, + NewRoundReq { + label: "Race".into(), + classes: vec![], + format: "timed_qual".into(), + params: std::collections::BTreeMap::new(), + win_condition: Some(win_condition), + seeding: SeedingRule::FromRoster, + time_limit_secs, + channel_mode: None, + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }, + ) + .expect("round adds (empty classes validate)"); + + let heat = || HeatId("q-1".into()); + let changed = |t| Event::HeatStateChanged { + heat: heat(), + transition: t, + }; + let mut events = vec![ + Event::HeatScheduled { + heat: heat(), + lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + class: None, + round: Some(round.id.clone()), + frequencies: vec![], + label: None, + }, + changed(HeatTransition::Staged), + changed(HeatTransition::Armed), + changed(HeatTransition::Running), + ]; + events.extend(passes); + events.push(changed(HeatTransition::Finished)); + events.push(changed(HeatTransition::Finalized)); + + let state = registry + .resolve(&event_id) + .expect("Practice is always present"); + for e in &events { + state.append(e.clone(), None).unwrap(); + } + registry + } + + use crate::events::{EventRegistry, PRACTICE_EVENT_ID}; + + // The per-event route prefix the tests drive is `/events/practice` — the always-present + // in-memory Practice event (#72); every snapshot/control/auth path is rooted under it. + + /// Build a registry whose **Practice** event log already holds `events`, returning the + /// registry (the router state), the Practice [`AppState`] (for token minting in tests), + /// and the log length. Practice is in-memory, so the seed is just appends to its log. + fn state_with(events: Vec) -> (EventRegistry, AppState, u64) { + let registry = EventRegistry::new(None).unwrap(); + let state = registry + .resolve(&EventId(PRACTICE_EVENT_ID.into())) + .expect("Practice is always present"); + for e in &events { + state.append(e.clone(), None).unwrap(); + } + let len = events.len() as u64; + (registry, state, len) + } + + async fn get_snapshot(registry: EventRegistry, uri: &str) -> (StatusCode, Option) { + let response = router(registry) + .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 (registry, _state, len) = state_with(recorded_heat()); + let (status, snap) = + get_snapshot(registry, "/events/practice/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::Final); + 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 (registry, _state, len) = state_with(recorded_heat()); + let (status, snap) = get_snapshot(registry, "/events/practice/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(_))); + } + + /// Regression for #249: a `randomized-delay` round arms a heat and the start driver appends + /// `HeatStarting { delay_ms }` with **no caller timestamp**. The append choke point must stamp + /// its server `recorded_at` (like a heat transition) so `heat_tone_at` can anchor the Armed-phase + /// tone countdown to it. Before the fix `HeatStarting` was untimed, `tone_at` was always `None`, + /// and the RD's "Tone in S.s" countdown never showed. Here we drive a heat Scheduled → Staged → + /// Armed, append an *untimed* `HeatStarting`, and assert the heat-scope live state surfaces a + /// `tone_at` in the future (now + the logged delay) while `Armed`. + #[tokio::test] + async fn armed_heat_surfaces_tone_at_from_an_untimed_heat_starting() { + let heat = || HeatId("q-1".into()); + let changed = |t| Event::HeatStateChanged { + heat: heat(), + transition: t, + }; + let (registry, state, _) = state_with(vec![ + Event::HeatScheduled { + heat: heat(), + lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + changed(HeatTransition::Staged), + changed(HeatTransition::Armed), + ]); + + // The start driver's append: an untimed `HeatStarting`. The choke point must stamp it. + let before = now_micros(); + let delay_ms: u32 = 3000; + state + .append( + Event::HeatStarting { + heat: heat(), + delay_ms, + }, + None, + ) + .unwrap(); + let after = now_micros(); + + // The stored entry carries a server `recorded_at` (no longer an untimed entry). + let (stored, _) = state.read_stored().unwrap(); + let starting = stored + .iter() + .find(|s| matches!(&s.event, Event::HeatStarting { .. })) + .expect("the HeatStarting was appended"); + let armed_at = starting + .recorded_at + .expect("the append choke point stamped HeatStarting's recorded_at (#249)"); + assert!(before <= armed_at && armed_at <= after); + + // The heat-scope live state surfaces the tone instant while Armed: armed_at + delay. + let (status, snap) = get_snapshot(registry, "/events/practice/snapshot/heat/q-1").await; + assert_eq!(status, StatusCode::OK); + match snap.unwrap().body { + ProjectionBody::LiveRaceState(live) => { + assert_eq!(live.phase, HeatPhase::Armed); + assert_eq!(live.tone_at, Some(armed_at + i64::from(delay_ms) * 1_000)); + } + other => panic!("expected live race state, got {other:?}"), + } + } + + /// Once the heat is `Running`, the tone has fired: `tone_at` clears (the countdown ends and + /// `race_started_at` takes over). Guards the Armed-only gating end-to-end through the snapshot. + #[tokio::test] + async fn running_heat_clears_tone_at() { + let heat = || HeatId("q-1".into()); + let changed = |t| Event::HeatStateChanged { + heat: heat(), + transition: t, + }; + let (registry, state, _) = state_with(vec![ + Event::HeatScheduled { + heat: heat(), + lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + changed(HeatTransition::Staged), + changed(HeatTransition::Armed), + ]); + state + .append( + Event::HeatStarting { + heat: heat(), + delay_ms: 3000, + }, + None, + ) + .unwrap(); + // The runtime's auto Armed → Running (the tone fired). + state + .append(changed(HeatTransition::Running), None) + .unwrap(); + + let (status, snap) = get_snapshot(registry, "/events/practice/snapshot/heat/q-1").await; + assert_eq!(status, StatusCode::OK); + match snap.unwrap().body { + ProjectionBody::LiveRaceState(live) => { + assert_eq!(live.phase, HeatPhase::Running); + assert_eq!(live.tone_at, None); + assert!(live.race_started_at.is_some()); + } + other => panic!("expected live race state, got {other:?}"), + } + } + + #[tokio::test] + async fn heat_scope_laps_projection_returns_lap_list() { + let (registry, _state, _) = state_with(recorded_heat()); + let (status, snap) = get_snapshot( + registry, + "/events/practice/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 (registry, _state, _) = state_with(recorded_heat()); + let (status, snap) = get_snapshot( + registry, + "/events/practice/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 heat_scope_result_uses_round_first_to_laps_win_condition() { + // A completes three laps earlier than B, but B holds the single fastest lap (0.5s). Under + // the round's First-to-3 win condition the order is by who reached lap 3 first (A), and the + // placement metric is `ReachedAt` — NOT the hardcoded best-lap placeholder (#45) that would + // have ordered B first by best lap. + let passes = vec![ + pass("A", 0, 1), + pass("B", 0, 1), + pass("A", 1_000_000, 2), // A lap 1 = 1.0s + pass("B", 500_000, 2), // B lap 1 = 0.5s (the single fastest lap) + pass("A", 2_000_000, 3), // A lap 2 = 1.0s + pass("A", 3_000_000, 4), // A reaches lap 3 at t = 3.0s + pass("B", 3_500_000, 3), // B lap 2 = 3.0s + pass("B", 4_000_000, 4), // B reaches lap 3 at t = 4.0s + ]; + let registry = registry_with_round_heat(WinCondition::FirstToLaps { n: 3 }, None, passes); + let (status, snap) = get_snapshot( + registry, + "/events/practice/snapshot/heat/q-1?projection=result", + ) + .await; + assert_eq!(status, StatusCode::OK); + match snap.unwrap().body { + ProjectionBody::HeatResult(result) => { + assert_eq!(result.places.len(), 2); + // A first: reached lap 3 at 3.0s (before B's 4.0s) — by the race, not best lap. + let first = &result.places[0]; + assert_eq!(first.competitor.competitor, CompetitorRef("A".into())); + assert_eq!(first.position, 1); + assert_eq!( + first.metric, + Metric::ReachedAt(Some(SourceTime::from_micros(3_000_000))) + ); + let second = &result.places[1]; + assert_eq!(second.competitor.competitor, CompetitorRef("B".into())); + assert_eq!( + second.metric, + Metric::ReachedAt(Some(SourceTime::from_micros(4_000_000))) + ); + } + other => panic!("expected heat result, got {other:?}"), + } + } + + #[tokio::test] + async fn heat_scope_result_uses_round_best_consecutive_win_condition() { + // A best-2-consecutive round: the placement metric is `BestConsecutiveMicros`, ranking by + // the smallest 2-lap window (A's 2.0s beats B's 6.0s). + let passes = vec![ + pass("A", 0, 1), + pass("A", 1_000_000, 2), // 1.0s + pass("A", 2_000_000, 3), // 1.0s → best 2-consec = 2.0s + pass("B", 0, 1), + pass("B", 3_000_000, 2), // 3.0s + pass("B", 6_000_000, 3), // 3.0s → best 2-consec = 6.0s + ]; + let registry = + registry_with_round_heat(WinCondition::BestConsecutive { n: 2 }, Some(60), passes); + let (status, snap) = get_snapshot( + registry, + "/events/practice/snapshot/heat/q-1?projection=result", + ) + .await; + assert_eq!(status, StatusCode::OK); + match snap.unwrap().body { + ProjectionBody::HeatResult(result) => { + let first = &result.places[0]; + assert_eq!(first.competitor.competitor, CompetitorRef("A".into())); + assert_eq!(first.position, 1); + assert_eq!(first.metric, Metric::BestConsecutiveMicros(Some(2_000_000))); + } + other => panic!("expected heat result, got {other:?}"), + } + } + + #[tokio::test] + async fn heat_scope_result_no_round_falls_back_to_best_lap() { + // `recorded_heat`'s q-1 carries `round: None` (an ad-hoc heat with no round), so the result + // still scores under the best-lap fallback — the placement metric is `BestLapMicros`, so the + // un-tagged heat's behaviour is unchanged. A's fastest lap (2.5s) beats B's (4.0s). + let (registry, _state, _) = state_with(recorded_heat()); + let (status, snap) = get_snapshot( + registry, + "/events/practice/snapshot/heat/q-1?projection=result", + ) + .await; + assert_eq!(status, StatusCode::OK); + match snap.unwrap().body { + ProjectionBody::HeatResult(result) => { + let first = &result.places[0]; + assert_eq!(first.competitor.competitor, CompetitorRef("A".into())); + assert_eq!(first.metric, Metric::BestLapMicros(Some(2_500_000))); + } + other => panic!("expected heat result, got {other:?}"), + } + } + + #[tokio::test] + async fn heat_scope_audit_projection_returns_marshaling_trail() { + // Seed a heat plus two rulings: void B's first pass, then DQ A. The audit returns both, + // newest first, with NO automatic passes. + let mut events = recorded_heat(); + events.push(Event::DetectionVoided { + target: LogRef(5), // global offset of B's first pass in `recorded_heat` + }); + events.push(Event::PenaltyApplied { + heat: HeatId("q-1".into()), + competitor: CompetitorRef("A".into()), + penalty: gridfpv_events::Penalty::Disqualify { reason: None }, + }); + let (registry, _state, _) = state_with(events); + let (status, snap) = get_snapshot( + registry, + "/events/practice/snapshot/heat/q-1?projection=audit", + ) + .await; + assert_eq!(status, StatusCode::OK); + match snap.unwrap().body { + ProjectionBody::MarshalingAudit(trail) => { + assert_eq!(trail.len(), 2, "two rulings, no passes"); + // Newest first: the DQ. + assert_eq!(trail[0].kind, gridfpv_projection::AuditKind::PenaltyApplied); + assert_eq!(trail[1].kind, gridfpv_projection::AuditKind::Voided); + } + other => panic!("expected marshaling audit, got {other:?}"), + } + } + + // --- The event-wide audit read (`GET /events/{event_id}/audit`) ----------------------------- + + /// `GET /events/practice/audit`, deserialized. The route serves plain `Vec` + /// (no snapshot envelope — it is a directory-style read like `/heats`). + async fn get_event_audit(registry: EventRegistry) -> (StatusCode, Vec) { + let response = router(registry) + .oneshot( + Request::builder() + .uri("/events/practice/audit") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let entries = serde_json::from_slice::>(&bytes).unwrap_or_default(); + (status, entries) + } + + /// The heat-loop events for a second heat `q-2` (C and D), appended after `recorded_heat`'s + /// `q-1`. With `recorded_heat` first (offsets 0..=10), these land at offsets 11..=18 — the + /// two passes at 15 and 16. + fn second_heat() -> Vec { + let changed = |t| Event::HeatStateChanged { + heat: HeatId("q-2".into()), + transition: t, + }; + vec![ + Event::HeatScheduled { + heat: HeatId("q-2".into()), + lineup: vec![CompetitorRef("C".into()), CompetitorRef("D".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + changed(HeatTransition::Staged), + changed(HeatTransition::Armed), + changed(HeatTransition::Running), + pass("C", 1_000_000, 1), + pass("C", 4_000_000, 2), + changed(HeatTransition::Finished), + changed(HeatTransition::Finalized), + ] + } + + #[tokio::test] + async fn event_audit_merges_heats_newest_first_with_correct_heat_tags() { + // Two heats run back-to-back, then two rulings appended AFTER both have run: first a void + // targeting q-2's second pass (offset 16 → window-attributed to q-2), then a penalty + // heat-tagged to q-1 — the FIRST heat, filed while it is long finished. The tag (not the + // position in the log) must decide the heat it lands under. + let mut events = recorded_heat(); // q-1 at offsets 0..=10 + events.extend(second_heat()); // q-2 at offsets 11..=18 + events.push(Event::DetectionVoided { + target: LogRef(16), // q-2's second pass → belongs to q-2 + }); + events.push(Event::PenaltyApplied { + heat: HeatId("q-1".into()), + competitor: CompetitorRef("A".into()), + penalty: gridfpv_events::Penalty::Disqualify { reason: None }, + }); + let (registry, _state, _) = state_with(events); + + let (status, entries) = get_event_audit(registry).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(entries.len(), 2, "two rulings, no passes"); + // Newest first: the penalty (offset 20) precedes the void (offset 19)… + assert_eq!(entries[0].entry.at_ref, LogRef(20)); + assert_eq!( + entries[0].entry.kind, + gridfpv_projection::AuditKind::PenaltyApplied + ); + // …and it is tagged to q-1 (the heat it names), NOT q-2 (the heat last active in the log). + assert_eq!(entries[0].heat, HeatId("q-1".into())); + assert_eq!(entries[1].entry.at_ref, LogRef(19)); + assert_eq!(entries[1].entry.kind, gridfpv_projection::AuditKind::Voided); + assert_eq!(entries[1].heat, HeatId("q-2".into())); + } + + #[tokio::test] + async fn event_audit_omits_a_restarted_heats_pre_restart_rulings() { + // A ruling made during q-1's FIRST run, then the heat is Restarted and re-raced. The heat + // window folds from the current run only (a reset abandons the prior run and everything + // ruled about it), so the pre-restart penalty must NOT appear in the event audit — while a + // post-restart ruling does. + let heat = || HeatId("q-1".into()); + let changed = |t| Event::HeatStateChanged { + heat: heat(), + transition: t, + }; + let events = vec![ + Event::HeatScheduled { + heat: heat(), + lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + changed(HeatTransition::Staged), + changed(HeatTransition::Armed), + changed(HeatTransition::Running), + pass("A", 1_000_000, 1), + // The abandoned run's ruling (offset 5): a penalty filed mid-first-run. + Event::PenaltyApplied { + heat: heat(), + competitor: CompetitorRef("A".into()), + penalty: gridfpv_events::Penalty::Disqualify { reason: None }, + }, + changed(HeatTransition::Restarted), // offset 6 — abandons the run above + changed(HeatTransition::Staged), + changed(HeatTransition::Armed), + changed(HeatTransition::Running), + pass("A", 1_000_000, 2), + pass("A", 4_000_000, 3), + changed(HeatTransition::Finished), + changed(HeatTransition::Finalized), + // The current run's ruling (offset 14): survives. + Event::PenaltyApplied { + heat: heat(), + competitor: CompetitorRef("B".into()), + penalty: gridfpv_events::Penalty::TimeAdded { micros: 2_000_000 }, + }, + ]; + let (registry, _state, _) = state_with(events); + + let (status, entries) = get_event_audit(registry).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + entries.len(), + 1, + "only the current run's ruling — the pre-restart DQ is abandoned with its run" + ); + assert_eq!(entries[0].entry.at_ref, LogRef(14)); + assert_eq!(entries[0].heat, heat()); + assert_eq!(entries[0].entry.competitor, Some(CompetitorRef("B".into()))); + } + + #[tokio::test] + async fn event_audit_on_unknown_event_is_not_found() { + let (registry, _state, _) = state_with(recorded_heat()); + let response = router(registry) + .oneshot( + Request::builder() + .uri("/events/no-such-event/audit") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn heat_scope_signal_projection_returns_captured_trace() { + // Seed a heat with two signal chunks for node A plus thresholds; the signal projection + // folds them into a per-competitor trace scoped to the heat window. + let mut events = recorded_heat(); + events.push(Event::SignalThresholds(gridfpv_events::SignalThresholds { + adapter: AdapterId("rotorhazard".into()), + competitor: CompetitorRef("A".into()), + enter: 90, + exit: 80, + })); + events.push(Event::SignalChunk(gridfpv_events::SignalChunk { + adapter: AdapterId("rotorhazard".into()), + competitor: CompetitorRef("A".into()), + from: SourceTime::from_micros(0), + period_micros: 100_000, + rssi: vec![70, 150], + })); + events.push(Event::SignalChunk(gridfpv_events::SignalChunk { + adapter: AdapterId("rotorhazard".into()), + competitor: CompetitorRef("A".into()), + from: SourceTime::from_micros(200_000), + period_micros: 100_000, + rssi: vec![148, 70], + })); + let (registry, _state, _) = state_with(events); + let (status, snap) = get_snapshot( + registry, + "/events/practice/snapshot/heat/q-1?projection=signal", + ) + .await; + assert_eq!(status, StatusCode::OK); + match snap.unwrap().body { + ProjectionBody::SignalTrace(view) => { + assert_eq!(view.competitors.len(), 1); + let trace = &view.competitors[0]; + assert_eq!(trace.competitor.competitor, CompetitorRef("A".into())); + assert_eq!(trace.samples, vec![70, 150, 148, 70]); + assert_eq!(trace.enter, Some(90)); + assert_eq!(trace.exit, Some(80)); + } + other => panic!("expected signal trace, got {other:?}"), + } + } + + #[tokio::test] + async fn unknown_heat_is_not_found() { + let (registry, _state, _) = state_with(recorded_heat()); + let response = router(registry) + .oneshot( + Request::builder() + .uri("/events/practice/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 (registry, _state, len) = state_with(recorded_heat()); + let (status, snap) = + get_snapshot(registry, "/events/practice/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 pilot_scope_resolves_a_registered_pilot_to_its_bound_competitor() { + // Bind pilot "acroace" to (vd, A), then query the pilot by their PilotId — the + // snapshot resolves the binding and returns A's laps (#60). + let mut events = recorded_heat(); + events.push(Event::CompetitorRegistered { + adapter: AdapterId("vd".into()), + competitor: CompetitorRef("A".into()), + pilot: gridfpv_events::PilotId("acroace".into()), + }); + let (registry, _state, _) = state_with(events); + let (status, snap) = get_snapshot( + registry, + "/events/practice/snapshot/pilot/spring-cup/acroace", + ) + .await; + assert_eq!(status, StatusCode::OK); + match snap.unwrap().body { + ProjectionBody::LapList(laps) => { + 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 (registry, _state, _) = state_with(recorded_heat()); + let response = router(registry) + .oneshot( + Request::builder() + .uri("/events/practice/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 (registry, _state, len) = state_with(recorded_heat()); + let (status, snap) = + get_snapshot(registry, "/events/practice/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 class_scope_filters_to_the_class_heats() { + // Two heats in different classes; the class scope folds only its own class's heat. + // `open`'s heat ran A; `sport`'s heat ran B. The open class scope sees only A's racing. + let events = vec![ + Event::HeatScheduled { + heat: HeatId("o-1".into()), + lineup: vec![CompetitorRef("A".into())], + class: Some(ClassId("open".into())), + round: Some(RoundId("q1".into())), + frequencies: vec![], + label: None, + }, + Event::HeatStateChanged { + heat: HeatId("o-1".into()), + transition: HeatTransition::Running, + }, + pass("A", 1_000_000, 1), + pass("A", 4_000_000, 2), // open/A: one lap + Event::HeatScheduled { + heat: HeatId("s-1".into()), + lineup: vec![CompetitorRef("B".into())], + class: Some(ClassId("sport".into())), + round: Some(RoundId("q2".into())), + frequencies: vec![], + label: None, + }, + Event::HeatStateChanged { + heat: HeatId("s-1".into()), + transition: HeatTransition::Running, + }, + pass("B", 10_000_000, 1), + pass("B", 13_000_000, 2), + pass("B", 15_000_000, 3), // sport/B: two laps + ]; + let (registry, _state, _) = state_with(events); + + // The open class scope: current heat is open's, B (sport) never appears. + let (status, snap) = get_snapshot( + registry.clone(), + "/events/practice/snapshot/class/spring-cup/open", + ) + .await; + assert_eq!(status, StatusCode::OK); + match snap.unwrap().body { + ProjectionBody::LiveRaceState(ls) => { + assert_eq!(ls.current_heat, Some(HeatId("o-1".into()))); + assert_eq!(ls.active_pilots, vec![CompetitorRef("A".into())]); + assert!( + !ls.running_order.contains(&CompetitorRef("B".into())), + "sport's pilot does not appear in the open class scope" + ); + } + other => panic!("expected live state, got {other:?}"), + } + + // And the sport scope sees only B. + let (_, snap) = + get_snapshot(registry, "/events/practice/snapshot/class/spring-cup/sport").await; + match snap.unwrap().body { + ProjectionBody::LiveRaceState(ls) => { + assert_eq!(ls.current_heat, Some(HeatId("s-1".into()))); + assert_eq!(ls.active_pilots, vec![CompetitorRef("B".into())]); + } + other => panic!("expected live state, got {other:?}"), + } + } + + #[tokio::test] + async fn empty_log_event_scope_is_idle_with_zero_cursor() { + let (registry, _state, _) = state_with(vec![]); + let (status, snap) = + get_snapshot(registry, "/events/practice/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())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + 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())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + 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 (registry, _state, _) = state_with(events); + + let (_, snap) = get_snapshot( + registry.clone(), + "/events/practice/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( + registry, + "/events/practice/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:?}"), + } + } + + // --- #64: unknown API-tree paths are a typed 404, not the SPA shell ----------------- + + /// Drive a request against the bare protocol `router` (no SPA composed) and return the + /// status plus the parsed [`ProtocolError`] body, if the body is one. + async fn get_raw(registry: EventRegistry, uri: &str) -> (StatusCode, Option) { + let response = router(registry) + .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 err = serde_json::from_slice::(&bytes).ok(); + (status, err) + } + + #[test] + fn is_api_path_matches_only_the_known_trees() { + // Exact prefix and any `/`-continuation are API paths. + for p in [ + "/health", + "/snapshot", + "/snapshot/zzz/q-1", + "/stream", + "/control", + "/control/bogus", + "/auth", + "/auth/join-token", + ] { + assert!(is_api_path(p), "{p} should be an API path"); + } + // A bare client-side route — and a prefix that is only a substring — are NOT. + for p in [ + "/", + "/heats/q-1/live", + "/snapshotxyz", + "/streaming", + "/index.html", + ] { + assert!(!is_api_path(p), "{p} should NOT be an API path"); + } + } + + #[tokio::test] + async fn unknown_snapshot_route_is_typed_404_not_spa() { + // A wrong /snapshot/... shape (an extra/garbage segment) matched no route → typed 404. + let (registry, _state, _) = state_with(recorded_heat()); + let (status, err) = get_raw(registry, "/snapshot/zzz/nope/extra").await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!( + err.expect("a ProtocolError body").code, + ErrorCode::UnknownScope + ); + } + + #[tokio::test] + async fn bogus_control_path_is_typed_404() { + let (registry, _state, _) = state_with(recorded_heat()); + let (status, err) = get_raw(registry, "/control/bogus").await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!( + err.expect("a ProtocolError body").code, + ErrorCode::UnknownScope + ); + } + + #[tokio::test] + async fn a_real_route_still_works_alongside_the_api_fallback() { + let (registry, _state, _) = state_with(recorded_heat()); + let (status, snap) = get_snapshot(registry, "/events/practice/snapshot/heat/q-1").await; + assert_eq!(status, StatusCode::OK); + assert!(matches!( + snap.unwrap().body, + ProjectionBody::LiveRaceState(_) + )); + } + + #[tokio::test] + async fn smart_fallback_serves_spa_for_non_api_routes_and_404s_api_ones() { + use axum::response::Html; + + // An inner "SPA" service that returns a recognisable shell for any path. + let spa = tower::service_fn(|_req: Request| async { + Ok::<_, std::convert::Infallible>(Html("RD Console").into_response()) + }); + let app = smart_fallback(spa); + + // A genuine client-side route → the SPA shell (200), not a typed 404. + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/heats/q-1/live") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + assert!(String::from_utf8_lossy(&bytes).contains("RD Console")); + + // A mistyped API path → typed 404 ProtocolError, NOT the SPA shell. + let response = app + .oneshot( + Request::builder() + .uri("/snapshot/zzz") + .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); + } + + // --- #90: the Director's active event over HTTP ------------------------------------- + + /// `GET /active-event` → status + parsed `ActiveEvent`. + async fn get_active(registry: EventRegistry) -> (StatusCode, Option) { + let response = router(registry) + .oneshot( + Request::builder() + .uri("/active-event") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body = serde_json::from_slice::(&bytes).ok(); + (status, body) + } + + /// `PUT /active-event` with `{ id }` and an optional bearer token → status + parsed body. + async fn put_active( + registry: EventRegistry, + id: &str, + token: Option<&str>, + ) -> (StatusCode, Vec) { + let mut builder = Request::builder() + .method("PUT") + .uri("/active-event") + .header("Content-Type", "application/json"); + if let Some(token) = token { + builder = builder.header("Authorization", format!("Bearer {token}")); + } + let json = serde_json::to_string(&SetActiveEventRequest { + id: EventId(id.to_string()), + }) + .unwrap(); + let response = router(registry) + .oneshot(builder.body(Body::from(json)).unwrap()) + .await + .unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + (status, bytes.to_vec()) + } + + #[tokio::test] + async fn active_event_is_none_until_set_then_resumes() { + let (registry, _state, _) = state_with(recorded_heat()); + + // A fresh Director has no active event → the picker. + let (status, body) = get_active(registry.clone()).await; + assert_eq!(status, StatusCode::OK); + assert!(body.expect("an ActiveEvent body").event.is_none()); + + // Setting it (open Director — no token needed) returns Practice's meta… + let (status, raw) = put_active(registry.clone(), PRACTICE_EVENT_ID, None).await; + assert_eq!(status, StatusCode::OK); + let meta: EventMeta = serde_json::from_slice(&raw).unwrap(); + assert_eq!(meta.id.0, PRACTICE_EVENT_ID); + + // …and now the open read resumes into it. + let (_, body) = get_active(registry).await; + assert_eq!( + body.unwrap().event.map(|m| m.id.0), + Some(PRACTICE_EVENT_ID.to_string()) + ); + } + + #[tokio::test] + async fn setting_an_unknown_active_event_is_404() { + let (registry, _state, _) = state_with(recorded_heat()); + let (status, raw) = put_active(registry, "no-such-event", None).await; + assert_eq!(status, StatusCode::NOT_FOUND); + let err: ProtocolError = serde_json::from_slice(&raw).unwrap(); + assert_eq!(err.code, ErrorCode::UnknownScope); + } + + #[tokio::test] + async fn setting_the_active_event_requires_an_rd_token_once_configured() { + let (registry, state, _) = state_with(recorded_heat()); + // Configure a control credential so the full-trust default closes. + let _rd = state.tokens().issue_rd_token(); + let (status, _) = put_active(registry, PRACTICE_EVENT_ID, None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } + + // --- DELETE /events/{id}: permanent delete of an event + all its data (papercut) ---- + + /// `DELETE /events/{id}` with an optional bearer token → status + raw body bytes. + async fn delete_event_req( + registry: EventRegistry, + id: &str, + token: Option<&str>, + ) -> (StatusCode, Vec) { + let mut builder = Request::builder() + .method("DELETE") + .uri(format!("/events/{id}")); + if let Some(token) = token { + builder = builder.header("Authorization", format!("Bearer {token}")); + } + let response = router(registry) + .oneshot(builder.body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + (status, bytes.to_vec()) + } + + #[tokio::test] + async fn delete_event_removes_a_created_event() { + let (registry, _state, _) = state_with(recorded_heat()); + // Create a real event, then delete it through the route. + let created = registry + .create(&crate::events::CreateEventRequest { + name: "Doomed".into(), + date: None, + location: None, + description: None, + organizer: None, + }) + .unwrap(); + assert!(registry.resolve(&created.id).is_some()); + + let (status, _) = delete_event_req(registry.clone(), &created.id.0, None).await; + assert_eq!(status, StatusCode::OK); + // Gone from the registry and the listing. + assert!(registry.resolve(&created.id).is_none()); + assert!(!registry.list().iter().any(|m| m.id == created.id)); + } + + #[tokio::test] + async fn delete_event_rejects_practice_and_unknown() { + let (registry, _state, _) = state_with(recorded_heat()); + // Practice cannot be deleted → BadRequest (400), and it still resolves. + let (status, raw) = delete_event_req(registry.clone(), PRACTICE_EVENT_ID, None).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + let err: ProtocolError = serde_json::from_slice(&raw).unwrap(); + assert_eq!(err.code, ErrorCode::BadRequest); + assert!( + registry + .resolve(&EventId(PRACTICE_EVENT_ID.into())) + .is_some() + ); + + // An unknown id → a typed 404 (UnknownScope). + let (status, raw) = delete_event_req(registry, "no-such-event", None).await; + assert_eq!(status, StatusCode::NOT_FOUND); + let err: ProtocolError = serde_json::from_slice(&raw).unwrap(); + assert_eq!(err.code, ErrorCode::UnknownScope); + } + + #[tokio::test] + async fn delete_event_requires_an_rd_token_once_configured() { + let (registry, state, _) = state_with(recorded_heat()); + let _rd = state.tokens().issue_rd_token(); + let created = registry + .create(&crate::events::CreateEventRequest { + name: "Gated".into(), + date: None, + location: None, + description: None, + organizer: None, + }) + .unwrap(); + let (status, _) = delete_event_req(registry.clone(), &created.id.0, None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + // The event is untouched after the rejected delete. + assert!(registry.resolve(&created.id).is_some()); + } + + // --- POST /events/{id}/control: a missing/wrong Content-Type → JSON ProtocolError ---- + + #[tokio::test] + async fn control_missing_content_type_is_a_json_protocol_error() { + // A control POST whose body is valid JSON but lacks the `Content-Type: application/json` + // header used to return a bare-text 4xx; it must now be the uniform `ProtocolError` JSON. + let (registry, _state, _) = state_with(recorded_heat()); + let command = serde_json::to_string(&crate::control::Command::Stage { + heat: HeatId("q-1".into()), + }) + .unwrap(); + let response = router(registry) + .oneshot( + Request::builder() + .method("POST") + .uri("/events/practice/control") + // No Content-Type header on purpose. + .body(Body::from(command)) + .unwrap(), + ) + .await + .unwrap(); + // A 400 with a parseable ProtocolError(BadRequest) body — not a bare-text 4xx. + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let err: ProtocolError = + serde_json::from_slice(&bytes).expect("the body is a JSON ProtocolError"); + assert_eq!(err.code, ErrorCode::BadRequest); + assert!(!err.message.is_empty()); + } + + #[tokio::test] + async fn control_malformed_json_body_is_a_json_protocol_error() { + // A correct Content-Type but an unparseable body is likewise a typed JSON error. + let (registry, _state, _) = state_with(recorded_heat()); + let response = router(registry) + .oneshot( + Request::builder() + .method("POST") + .uri("/events/practice/control") + .header("Content-Type", "application/json") + .body(Body::from("{ not valid json")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let err: ProtocolError = + serde_json::from_slice(&bytes).expect("the body is a JSON ProtocolError"); + assert_eq!(err.code, ErrorCode::BadRequest); + } + + // --- #63: minting a read-only join token over HTTP ---------------------------------- + + /// `POST /auth/join-token` with an optional bearer token; returns status + parsed body. + async fn post_join_token( + registry: EventRegistry, + token: Option<&str>, + ) -> (StatusCode, Option) { + let mut builder = Request::builder() + .method("POST") + .uri("/events/practice/auth/join-token"); + if let Some(token) = token { + builder = builder.header("Authorization", format!("Bearer {token}")); + } + let response = router(registry) + .oneshot(builder.body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body = serde_json::from_slice::(&bytes).ok(); + (status, body) + } + + #[tokio::test] + async fn rd_token_mints_a_join_token_that_reads_but_cannot_control() { + use crate::auth::Role; + + let (registry, state, _) = state_with(recorded_heat()); + let rd = state.tokens().issue_rd_token(); + + // An RD mints a fresh read-only join token over HTTP. + let (status, body) = post_join_token(registry.clone(), Some(&rd)).await; + assert_eq!(status, StatusCode::OK); + let join = body.expect("a JoinTokenResponse body").token; + assert!(!join.is_empty()); + + // The minted token authenticates a READ as a read-only session… + let read = state + .tokens() + .authenticate_read(Some(&join)) + .unwrap() + .expect("the minted token resolves a session"); + assert_eq!(read.role, Role::ReadOnly); + // …but is rejected on CONTROL. + assert_eq!( + state + .tokens() + .authenticate_control(Some(&join)) + .unwrap_err() + .code, + ErrorCode::Unauthorized + ); + } + + #[tokio::test] + async fn minting_a_join_token_requires_an_rd_token_once_one_is_configured() { + let (registry, state, _) = state_with(recorded_heat()); + // Configure a control credential so the full-trust default closes (#72, Slice 1b): + // without this, control is open and a no-token mint would succeed. + let _rd = state.tokens().issue_rd_token(); + + // No token → 401 (control is now gated). + let (status, _) = post_join_token(registry.clone(), None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + // A read-only/join token → 401 (it may not mint another). + let join = state.tokens().issue_join_token(); + let (status, _) = post_join_token(registry.clone(), Some(&join)).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + // An unknown token → 401. + let (status, _) = post_join_token(registry, Some("not-a-real-token")).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn minting_a_join_token_is_open_when_no_rd_token_is_configured() { + // Full-trust default (#72, Slice 1b): an unconfigured Director has no control + // credential, so a no-token caller may mint a join token (control is open). + let (registry, _state, _) = state_with(recorded_heat()); + let (status, body) = post_join_token(registry, None).await; + assert_eq!(status, StatusCode::OK); + assert!(body.is_some_and(|b| !b.token.is_empty())); + } + + // --- #73: the application-level timer registry + per-event selection ---------------- + + use crate::timers::{ + CreateTimerRequest, SetEventTimersRequest, Timer, TimerKind, UpdateTimerRequest, + }; + + /// `GET /timers` → status + parsed `Timer[]`. + async fn get_timers(registry: EventRegistry) -> (StatusCode, Vec) { + let response = router(registry) + .oneshot( + Request::builder() + .uri("/timers") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body = serde_json::from_slice::>(&bytes).unwrap_or_default(); + (status, body) + } + + /// `POST /timers` with a JSON body + optional token → status + raw bytes. + async fn post_timer( + registry: EventRegistry, + body: &CreateTimerRequest, + token: Option<&str>, + ) -> (StatusCode, Vec) { + let mut builder = Request::builder() + .method("POST") + .uri("/timers") + .header("Content-Type", "application/json"); + if let Some(t) = token { + builder = builder.header("Authorization", format!("Bearer {t}")); + } + let json = serde_json::to_string(body).unwrap(); + let response = router(registry) + .oneshot(builder.body(Body::from(json)).unwrap()) + .await + .unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + (status, bytes.to_vec()) + } + + #[tokio::test] + async fn timers_list_has_the_mock_first_and_is_open() { + let (registry, _state, _) = state_with(vec![]); + let (status, timers) = get_timers(registry).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(timers.first().unwrap().id.0, "mock"); + assert!(matches!(timers[0].kind, TimerKind::Mock { .. })); + } + + #[tokio::test] + async fn post_timer_creates_and_lists_it() { + let (registry, _state, _) = state_with(vec![]); + let body = CreateTimerRequest { + name: "Field RH".into(), + kind: TimerKind::Rotorhazard { + url: "http://rh.local:5000".into(), + }, + channel_capability: None, + node_count: None, + available_channels: None, + }; + let (status, raw) = post_timer(registry.clone(), &body, None).await; + assert_eq!(status, StatusCode::OK); + let created: Timer = serde_json::from_slice(&raw).unwrap(); + assert!(created.id.0.starts_with("field-rh-")); + + let (_, timers) = get_timers(registry).await; + assert!(timers.iter().any(|t| t.id == created.id)); + } + + #[tokio::test] + async fn post_timer_requires_an_rd_token_once_configured() { + let (registry, state, _) = state_with(vec![]); + let _rd = state.tokens().issue_rd_token(); + let body = CreateTimerRequest { + name: "Gated".into(), + kind: TimerKind::Mock { + laps: 1, + lap_ms: 50, + }, + channel_capability: None, + node_count: None, + available_channels: None, + }; + let (status, _) = post_timer(registry, &body, None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn delete_rejects_the_builtin_mock() { + let (registry, _state, _) = state_with(vec![]); + let response = router(registry) + .oneshot( + Request::builder() + .method("DELETE") + .uri("/timers/mock") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // Protected delete is a client error, not a 404. + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn put_event_timers_validates_and_sets_the_selection() { + let (registry, _state, _) = state_with(vec![]); + // Create a real timer to select. + let body = CreateTimerRequest { + name: "Extra Sim".into(), + kind: TimerKind::Mock { + laps: 1, + lap_ms: 50, + }, + channel_capability: None, + node_count: None, + available_channels: None, + }; + let (_, raw) = post_timer(registry.clone(), &body, None).await; + let extra: Timer = serde_json::from_slice(&raw).unwrap(); + + // Selecting a known timer succeeds and is reflected on the event meta. + let req = SetEventTimersRequest { + ids: vec![extra.id.clone()], + primary: None, + }; + let response = router(registry.clone()) + .oneshot( + Request::builder() + .method("PUT") + .uri("/events/practice/timers") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&req).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let meta: EventMeta = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(meta.timers, vec![extra.id]); + + // Selecting an UNKNOWN timer → 404 UnknownScope. + let bad = SetEventTimersRequest { + ids: vec![crate::timers::TimerId("no-such-timer".into())], + primary: None, + }; + let response = router(registry) + .oneshot( + Request::builder() + .method("PUT") + .uri("/events/practice/timers") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&bad).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn update_timer_retunes_the_mock() { + let (registry, _state, _) = state_with(vec![]); + let body = UpdateTimerRequest { + name: None, + kind: Some(TimerKind::Mock { + laps: 9, + lap_ms: 100, + }), + ..Default::default() + }; + let response = router(registry.clone()) + .oneshot( + Request::builder() + .method("PUT") + .uri("/timers/mock") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let (_, timers) = get_timers(registry).await; + let sim = timers.iter().find(|t| t.id.0 == "mock").unwrap(); + assert_eq!( + sim.kind, + TimerKind::Mock { + laps: 9, + lap_ms: 100 + } + ); + } + + // --- P2: timer config validation ---------------------------------------- + + #[tokio::test] + async fn post_timer_rejects_zero_node_count() { + let (registry, _state, _) = state_with(vec![]); + let body = CreateTimerRequest { + name: "Zero".into(), + kind: TimerKind::Mock { + laps: 1, + lap_ms: 50, + }, + channel_capability: None, + node_count: Some(0), + available_channels: None, + }; + let (status, _) = post_timer(registry, &body, None).await; + // A 0-node timer caps every heat to no pilots — rejected as a 400, not silently created. + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + // --- P1-5: membership is scoped to the event's roster + class selection -- + + /// Seed a class C (directory + **selected**) and pilot P (directory + **roster**) on Practice, + /// returning their ids alongside an extra pilot Q and class D that are in the directory but + /// *not* on the roster / selection. + fn membership_fixture( + registry: &EventRegistry, + ) -> (ClassId, ClassId, PilotId, PilotId, EventId) { + let event = EventId(PRACTICE_EVENT_ID.into()); + let class_c = registry + .classes() + .create(&CreateClassRequest { + name: "Open".into(), + ..Default::default() + }) + .unwrap() + .id; + let class_d = registry + .classes() + .create(&CreateClassRequest { + name: "Unselected".into(), + ..Default::default() + }) + .unwrap() + .id; + let pilot_p = registry + .pilots() + .create(&CreatePilotRequest { + callsign: "Rostered".into(), + ..Default::default() + }) + .unwrap() + .id; + let pilot_q = registry + .pilots() + .create(&CreatePilotRequest { + callsign: "Outsider".into(), + ..Default::default() + }) + .unwrap() + .id; + registry.set_classes(&event, vec![class_c.clone()]).unwrap(); + registry.set_roster(&event, vec![pilot_p.clone()]).unwrap(); + (class_c, class_d, pilot_p, pilot_q, event) + } + + async fn put_membership( + registry: EventRegistry, + event: &EventId, + class: &ClassId, + pilots: Vec, + ) -> StatusCode { + let body = SetClassMembershipRequest { + pilots: pilots.into_iter().map(MemberSlot::new).collect(), + }; + let response = router(registry) + .oneshot( + Request::builder() + .method("PUT") + .uri(format!( + "/events/{}/classes/{}/membership", + event.0, class.0 + )) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + response.status() + } + + #[tokio::test] + async fn put_membership_rejects_non_roster_pilot_and_non_selected_class() { + let (registry, _state, _) = state_with(vec![]); + let (class_c, class_d, pilot_p, pilot_q, event) = membership_fixture(®istry); + + // Happy path: a selected class + a rostered pilot is accepted. + assert_eq!( + put_membership(registry.clone(), &event, &class_c, vec![pilot_p.clone()]).await, + StatusCode::OK + ); + + // A pilot in the directory but NOT on the event roster → 400. + assert_eq!( + put_membership(registry.clone(), &event, &class_c, vec![pilot_q]).await, + StatusCode::BAD_REQUEST + ); + + // A class in the directory but NOT selected by the event → 400. + assert_eq!( + put_membership(registry, &event, &class_d, vec![pilot_p]).await, + StatusCode::BAD_REQUEST + ); + } + + // --- P1-7: a registry I/O failure maps to a 500, not a 404/400 ---------- + + #[test] + fn registry_error_kinds_map_to_the_right_status() { + use crate::events::RegistryError; + // An I/O / persistence failure is a 500 — the load-bearing case: in-memory state was already + // mutated, so it must NOT read as a 404/400. + assert_eq!( + registry_error_to_protocol(RegistryError::io("disk full")).code, + ErrorCode::Internal + ); + assert_eq!( + registry_error_to_protocol(RegistryError::not_found("nope")).code, + ErrorCode::UnknownScope + ); + assert_eq!( + registry_error_to_protocol(RegistryError::invalid("bad")).code, + ErrorCode::BadRequest + ); + } +} diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs new file mode 100644 index 0000000..e0e6191 --- /dev/null +++ b/crates/server/src/auth.rs @@ -0,0 +1,483 @@ +//! 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 serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::error::{ErrorCode, ProtocolError}; + +/// The body of a successful `POST /auth/join-token` (protocol.html §5, §9.4) — issue #63. +/// +/// A freshly-minted **read-only** join token, returned to an authenticated RD so it can +/// hand it (e.g. as a venue QR / share URL) to a spectator. The token authenticates LAN +/// **reads** but is rejected on control ([`Role::can_control`]). The single-field wire +/// shape leaves room to grow additively (an expiry, a scope) without breaking an older +/// client that reads only `token`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct JoinTokenResponse { + /// The opaque, URL/QR-safe read-only join token (see [`random_token`]). + pub token: String, +} + +/// 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 an **RD-role** session under a caller-supplied `token`, returning whether it + /// was registered. Unlike [`issue_rd_token`](TokenStore::issue_rd_token) (which mints a + /// random opaque token), this pins a *known* control token so a deployment can configure + /// a fixed credential — the Director uses it to honour a `GRIDFPV_RD_TOKEN` env, letting + /// an automated client (the e2e harness, the Tauri app under test) log in with a known + /// value. A blank token is rejected (`false`) so an empty env never registers an empty, + /// trivially-guessable key; otherwise the session is registered and `true` returned. + pub fn register_rd_token(&self, token: &str) -> bool { + if token.trim().is_empty() { + return false; + } + self.sessions + .write() + .expect("token store lock poisoned") + .insert(token.to_string(), Session { role: Role::Rd }); + true + } + + /// 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() + } + + /// Whether **any control credential is configured** — i.e. at least one registered + /// session may drive control ([`Role::can_control`]). + /// + /// This is the switch behind the **full-trust (open-by-default) control posture** + /// (issue #72, Slice 1b): the Director only registers an RD token when one is + /// *configured* (a `GRIDFPV_RD_TOKEN` env, or an RD minting one), so an unconfigured + /// Director has **no** control credential and [`authenticate_control`](Self::authenticate_control) + /// admits an anonymous caller. The moment a token is configured, control is gated again. + /// + /// Read-only/join tokens do **not** count — they can never control, so issuing a venue + /// QR never accidentally locks the control path. + /// + /// This is the **dev/local-trust** posture (safe on loopback / a trusted LAN). The proper + /// loopback-trust + remote-passphrase split (open on `127.0.0.1`, gated for a remote + /// binding) is tracked separately as #80 and is **not** built here. + pub fn has_control_credential(&self) -> bool { + self.sessions + .read() + .expect("token store lock poisoned") + .values() + .any(|s| s.role.can_control()) + } + + /// Authenticate a control caller (issue #72, Slice 1b — full-trust by default). + /// + /// Policy, in order: + /// - **No control credential configured** ([`has_control_credential`](Self::has_control_credential) + /// is `false`) ⇒ control is **OPEN (full trust)**: admit as [`Role::Rd`] regardless of + /// any token. A present token — even a stale/unknown or read-only one — is **ignored**: + /// there is nothing configured to validate it against, and "full trust" means a leftover + /// token (e.g. from when the Director was previously run gated) must not break it. + /// - **A control credential IS configured** ⇒ control is **gated**: a present control token + /// is validated (unknown/revoked or read-only ⇒ rejected); no token ⇒ rejected. + /// + /// This is the whole control policy in one place; [`ControlAuth`] is the only caller + /// (see [`crate::control_handler`]). The loopback/remote split is #80 (not built here). + /// + /// [`ControlAuth`]: crate::control_handler::ControlAuth + pub fn authenticate_control(&self, token: Option<&str>) -> Result { + // Full-trust first: when no control credential is configured, control is OPEN — + // admit as RD regardless of any token presented. A leftover/stale token (e.g. from + // when the Director was previously run gated) must NOT break an open Director; + // there is nothing to validate it against, so it is simply ignored. + if !self.has_control_credential() { + return Ok(Session { role: Role::Rd }); + } + // Gated: a credential IS configured, so a valid control token is required. + match token { + Some(token) => 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", + )), + }, + None => Err(ProtocolError::new( + ErrorCode::Unauthorized, + "control requires an Authorization: Bearer header", + )), + } + } + + /// 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, ProtocolError> { + match token { + None => Ok(None), + Some(token) => self.session(token).map(Some).ok_or_else(|| { + ProtocolError::new(ErrorCode::Unauthorized, "unknown or revoked read token") + }), + } + } +} + +/// Draw an opaque, URL-safe token from the OS CSPRNG (protocol.html §9.4 "opaque token"). +/// +/// 32 random bytes (256 bits — unguessable) rendered as URL-safe base64 without padding, +/// so the string drops straight into a `Bearer` header, a query parameter, or a QR/URL +/// for the join-token. Random + opaque means the token carries no authority itself; the +/// [`TokenStore`] is the sole authority (so revocation is instant). Real signed tokens are +/// deferred (see the module docs). +fn random_token() -> String { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).expect("OS CSPRNG unavailable"); + url_safe_base64(&bytes) +} + +/// URL-safe base64 (RFC 4648 §5) without padding — enough to render a random token as a +/// compact, header/URL/QR-safe string. Hand-rolled to avoid pulling a base64 dependency +/// for one tiny use. +fn url_safe_base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = *chunk.get(1).unwrap_or(&0) as u32; + let b2 = *chunk.get(2).unwrap_or(&0) as u32; + let n = (b0 << 16) | (b1 << 8) | b2; + out.push(ALPHABET[(n >> 18 & 0x3f) as usize] as char); + out.push(ALPHABET[(n >> 12 & 0x3f) as usize] as char); + if chunk.len() > 1 { + out.push(ALPHABET[(n >> 6 & 0x3f) as usize] as char); + } + if chunk.len() > 2 { + out.push(ALPHABET[(n & 0x3f) as usize] as char); + } + } + out +} + +/// Read a bearer token from an `Authorization: Bearer ` header on a request's +/// [`Parts`], if present and well-formed. +/// +/// Returns `None` for a missing header or one that is not the `Bearer` scheme — the +/// caller (`authenticate_*`) decides whether absence is an error (control) or allowed +/// (reads). The scheme match is case-insensitive per RFC 7235. +pub fn bearer_token(parts: &Parts) -> Option { + let header = parts.headers.get(AUTHORIZATION)?.to_str().ok()?; + let (scheme, token) = header.split_once(' ')?; + if scheme.eq_ignore_ascii_case("Bearer") { + let token = token.trim(); + if token.is_empty() { + None + } else { + Some(token.to_string()) + } + } else { + None + } +} + +/// An axum extractor that resolves the caller's [`Session`] from the bearer token, for +/// **read** paths that want to know the caller's role (open if absent). +/// +/// `Some(session)` for a valid token of either role; `None` for an anonymous (no-token) +/// caller, which reads allow (protocol.html §5). A *present but invalid* token is a +/// rejection — so a stale join-token surfaces as [`ErrorCode::Unauthorized`] rather than +/// silently downgrading to anonymous. +#[derive(Debug, Clone)] +pub struct ReadAuth(pub Option); + +impl FromRequestParts for ReadAuth { + type Rejection = ProtocolError; + + async fn from_request_parts( + parts: &mut Parts, + state: &crate::app::AppState, + ) -> Result { + let token = bearer_token(parts); + let session = state.tokens().authenticate_read(token.as_deref())?; + Ok(ReadAuth(session)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rd_token_authenticates_control_and_round_trips() { + let store = TokenStore::new(); + let token = store.issue_rd_token(); + let session = store.authenticate_control(Some(&token)).unwrap(); + assert_eq!(session.role, Role::Rd); + } + + #[test] + fn join_token_is_read_only_and_rejected_on_control_when_gated() { + let store = TokenStore::new(); + store.issue_rd_token(); // gate control so a presented token is actually validated + let token = store.issue_join_token(); + // Authenticates a read… + let read = store.authenticate_read(Some(&token)).unwrap().unwrap(); + assert_eq!(read.role, Role::ReadOnly); + // …but never control (on a gated Director). + let err = store.authenticate_control(Some(&token)).unwrap_err(); + assert_eq!(err.code, ErrorCode::Unauthorized); + } + + #[test] + fn no_token_is_open_on_control_when_nothing_configured_and_always_open_on_read() { + // Full-trust (#72, Slice 1b): an unconfigured store has no control credential, so a + // no-token control caller is admitted as an RD; reads are open regardless. + let store = TokenStore::new(); + assert!(!store.has_control_credential()); + let session = store.authenticate_control(None).unwrap(); + assert_eq!(session.role, Role::Rd); + assert!(store.authenticate_read(None).unwrap().is_none()); + } + + #[test] + fn no_token_is_rejected_on_control_once_a_control_token_is_configured() { + // The moment an RD token is configured the open posture closes: a no-token control + // caller is rejected, but the configured token still works. + let store = TokenStore::new(); + let token = store.issue_rd_token(); + assert!(store.has_control_credential()); + assert_eq!( + store.authenticate_control(None).unwrap_err().code, + ErrorCode::Unauthorized + ); + assert!(store.authenticate_control(Some(&token)).is_ok()); + } + + #[test] + fn a_join_token_alone_does_not_configure_control_so_control_stays_open() { + // A read-only join token can never control, so issuing one (a venue QR) must not + // accidentally lock the open control path. + let store = TokenStore::new(); + let _join = store.issue_join_token(); + assert!(!store.has_control_credential()); + assert!(store.authenticate_control(None).is_ok()); + } + + #[test] + fn a_present_token_is_ignored_on_an_open_director() { + // Full-trust (#72): with nothing configured (open), control admits as RD regardless of + // any token — a stale/unknown token, or even a read-only one, is IGNORED, not rejected. + // A leftover token from a previously-gated run must never break an open Director. + let store = TokenStore::new(); + assert_eq!( + store.authenticate_control(Some("stale")).unwrap().role, + Role::Rd + ); + let join = store.issue_join_token(); // a join token still does not gate control + assert!(!store.has_control_credential()); + assert_eq!( + store.authenticate_control(Some(&join)).unwrap().role, + Role::Rd + ); + } + + #[test] + fn revoked_token_stops_working_while_control_stays_gated() { + let store = TokenStore::new(); + let keep = store.issue_rd_token(); // a second credential keeps control gated post-revoke + let token = store.issue_rd_token(); + assert!(store.authenticate_control(Some(&token)).is_ok()); + assert!(store.revoke(&token)); + // Still gated (the `keep` credential remains), so the revoked token is now rejected. + assert!(store.has_control_credential()); + assert_eq!( + store.authenticate_control(Some(&token)).unwrap_err().code, + ErrorCode::Unauthorized + ); + assert!(store.authenticate_control(Some(&keep)).is_ok()); + // A second revoke is a harmless no-op. + assert!(!store.revoke(&token)); + } + + #[test] + fn unknown_token_is_unauthorized_on_both_paths_when_gated() { + let store = TokenStore::new(); + store.issue_rd_token(); // gate control so an unknown token is validated + rejected + assert_eq!( + store.authenticate_control(Some("nope")).unwrap_err().code, + ErrorCode::Unauthorized + ); + assert_eq!( + store.authenticate_read(Some("nope")).unwrap_err().code, + ErrorCode::Unauthorized + ); + } + + #[test] + fn tokens_are_distinct_and_url_safe() { + let store = TokenStore::new(); + let a = store.issue_rd_token(); + let b = store.issue_rd_token(); + assert_ne!(a, b, "each token is freshly random"); + assert!( + a.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), + "token is URL/QR-safe: {a}" + ); + } + + #[test] + fn base64_matches_a_known_vector() { + // "Man" -> "TWFu" in standard/url-safe base64 (no padding needed, length 3). + assert_eq!(url_safe_base64(b"Man"), "TWFu"); + // A length that needs the 2-char tail: "M" -> "TQ". + assert_eq!(url_safe_base64(b"M"), "TQ"); + } +} diff --git a/crates/server/src/channels.rs b/crates/server/src/channels.rs new file mode 100644 index 0000000..b27c7ab --- /dev/null +++ b/crates/server/src/channels.rs @@ -0,0 +1,232 @@ +//! The standard **FPV channel catalog** (race redesign Slice 4a) — the shared vocabulary of +//! common analog (and clean digital) video channels mapped to their raw centre frequency in +//! **MHz**. +//! +//! Channels in FPV are spoken of by *band + channel label* (e.g. "Raceband R1", "Fatshark F4", +//! "Boscam A8"), but the engine — and every timer — only deals in the raw centre **frequency** +//! ([`gridfpv_engine::schedule::Frequency`], raw MHz). This module is the lookup table between the +//! two: a single, generic, *not* RotorHazard-specific reference the whole system shares. +//! +//! - The **UI** (Slice 4b) reads [`catalog`] (exported as the `ChannelCatalogEntry[]` binding) to +//! offer the human-readable band/channel labels when a Race Director configures a timer's +//! available channels. +//! - A **timer adapter** declares its [`channel_capability`](crate::timers::ChannelCapability) in +//! terms of these raw frequencies; a limited (Fixed) timer exposes only the catalog entries it +//! physically supports, a Flexible timer the whole catalog plus arbitrary custom MHz. +//! - **Per-heat assignment** ([`crate::round_engine`]) allocates these raw frequencies onto a +//! heat's lineup with the engine's [`allocate`](gridfpv_engine::schedule::allocate). +//! +//! # The bands (centre frequencies, MHz) +//! +//! - **Raceband** R1–R8 — `5658, 5695, 5732, 5769, 5806, 5843, 5880, 5917`. The de-facto racing +//! default (mirrors [`FrequencyPool::raceband`](gridfpv_engine::schedule::FrequencyPool::raceband)). +//! - **Fatshark / ImmersionRC (IRC)** F1–F8 — `5740, 5760, 5780, 5800, 5820, 5840, 5860, 5880`. +//! - **Boscam A** A1–A8 — `5865, 5845, 5825, 5805, 5785, 5765, 5745, 5725` (descending, as labelled). +//! - **Boscam B** B1–B8 — `5733, 5752, 5771, 5790, 5809, 5828, 5847, 5866`. +//! - **Boscam E** E1–E8 — `5705, 5685, 5665, 5645, 5885, 5905, 5925, 5945`. +//! - **DJI** (digital, O3/O4-class analog-compatible centres) — the four clean DJI race channels +//! `5660, 5695, 5735, 5770` (DJI R1–R4 overlap Raceband but are exposed under their own band so a +//! DJI HD pilot picks a DJI label). +//! - **HDZero** (digital) — `5658, 5695, 5732, 5769, 5806, 5843, 5880, 5917` (HDZero races on the +//! Raceband grid; exposed under its own band label for a HDZero pilot). + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// One entry in the standard FPV channel catalog: a band, its channel label, and the raw centre +/// frequency in **MHz** (race redesign Slice 4a). +/// +/// The `band`/`channel` pair is the human-readable handle a console offers; `mhz` is the +/// authoritative value every timer and the engine actually tune/allocate on. Exported via +/// `ts_rs::TS` so the frontend can render the catalog directly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct ChannelCatalogEntry { + /// The band name (e.g. `"Raceband"`, `"Fatshark"`, `"Boscam A"`, `"DJI"`, `"HDZero"`). + pub band: String, + /// The channel label within the band (e.g. `"R1"`, `"F4"`, `"A8"`). + pub channel: String, + /// The channel's centre frequency in megahertz (the raw value the engine/timer use). + pub mhz: u16, +} + +impl ChannelCatalogEntry { + /// A catalog entry from its band, channel label, and centre frequency. + fn new(band: &str, channel: &str, mhz: u16) -> Self { + Self { + band: band.to_string(), + channel: channel.to_string(), + mhz, + } + } +} + +/// The eight Raceband centre frequencies R1–R8, in channel order — the de-facto racing default. +pub const RACEBAND_MHZ: [u16; 8] = [5658, 5695, 5732, 5769, 5806, 5843, 5880, 5917]; + +/// One labelled band: its name and the eight (label, MHz) channels, in channel order. +fn band(name: &str, channels: [(&'static str, u16); 8]) -> Vec { + channels + .into_iter() + .map(|(label, mhz)| ChannelCatalogEntry::new(name, label, mhz)) + .collect() +} + +/// The full standard FPV channel catalog (race redesign Slice 4a), in a stable, deterministic +/// order: Raceband, Fatshark/IRC, Boscam A/B/E, then the clean digital DJI and HDZero bands. +/// +/// This is the shared vocabulary the UI offers and a timer's available channels are picked from. +/// The order is fixed so the binding and any consumer is deterministic (the gen-drift check and +/// the catalog-sanity test both rely on it). +pub fn catalog() -> Vec { + let mut out = Vec::new(); + out.extend(band( + "Raceband", + [ + ("R1", 5658), + ("R2", 5695), + ("R3", 5732), + ("R4", 5769), + ("R5", 5806), + ("R6", 5843), + ("R7", 5880), + ("R8", 5917), + ], + )); + out.extend(band( + "Fatshark", + [ + ("F1", 5740), + ("F2", 5760), + ("F3", 5780), + ("F4", 5800), + ("F5", 5820), + ("F6", 5840), + ("F7", 5860), + ("F8", 5880), + ], + )); + out.extend(band( + "Boscam A", + [ + ("A1", 5865), + ("A2", 5845), + ("A3", 5825), + ("A4", 5805), + ("A5", 5785), + ("A6", 5765), + ("A7", 5745), + ("A8", 5725), + ], + )); + out.extend(band( + "Boscam B", + [ + ("B1", 5733), + ("B2", 5752), + ("B3", 5771), + ("B4", 5790), + ("B5", 5809), + ("B6", 5828), + ("B7", 5847), + ("B8", 5866), + ], + )); + out.extend(band( + "Boscam E", + [ + ("E1", 5705), + ("E2", 5685), + ("E3", 5665), + ("E4", 5645), + ("E5", 5885), + ("E6", 5905), + ("E7", 5925), + ("E8", 5945), + ], + )); + // Clean digital bands (exposed under their own labels so a DJI/HDZero pilot picks a DJI/HDZero + // channel, even where the centre frequency coincides with an analog grid). + out.extend( + [("R1", 5660u16), ("R2", 5695), ("R3", 5735), ("R4", 5770)] + .into_iter() + .map(|(label, mhz)| ChannelCatalogEntry::new("DJI", label, mhz)), + ); + out.extend(band( + "HDZero", + [ + ("R1", 5658), + ("R2", 5695), + ("R3", 5732), + ("R4", 5769), + ("R5", 5806), + ("R6", 5843), + ("R7", 5880), + ("R8", 5917), + ], + )); + out +} + +/// Whether `mhz` is a frequency the standard catalog knows (any band/channel maps to it). Used to +/// label a raw frequency, and as a sanity gate when a Fixed timer's allowed set is validated. +pub fn is_known(mhz: u16) -> bool { + catalog().iter().any(|e| e.mhz == mhz) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raceband_is_r1_through_r8_in_order() { + let race: Vec<_> = catalog() + .into_iter() + .filter(|e| e.band == "Raceband") + .collect(); + assert_eq!(race.len(), 8); + assert_eq!(race[0].channel, "R1"); + assert_eq!(race[0].mhz, 5658); + assert_eq!(race[7].channel, "R8"); + assert_eq!(race[7].mhz, 5917); + // The const mirrors the catalog's Raceband row exactly. + let mhz: Vec = race.iter().map(|e| e.mhz).collect(); + assert_eq!(mhz, RACEBAND_MHZ); + } + + #[test] + fn catalog_covers_every_required_band() { + let bands: std::collections::BTreeSet = + catalog().into_iter().map(|e| e.band).collect(); + for required in ["Raceband", "Fatshark", "Boscam A", "Boscam B", "Boscam E"] { + assert!( + bands.contains(required), + "catalog missing band {required:?}" + ); + } + // The clean digital bands are present too. + assert!(bands.contains("DJI")); + assert!(bands.contains("HDZero")); + } + + #[test] + fn every_entry_is_a_plausible_58ghz_channel() { + for entry in catalog() { + assert!( + (5600..=6000).contains(&entry.mhz), + "{} {} = {} MHz is outside the 5.8 GHz band", + entry.band, + entry.channel, + entry.mhz + ); + assert!(!entry.channel.is_empty()); + } + } + + #[test] + fn is_known_recognises_catalog_frequencies_only() { + assert!(is_known(5658)); // Raceband R1 + assert!(is_known(5800)); // Fatshark F4 + assert!(!is_known(1234)); // not an FPV channel + } +} diff --git a/crates/server/src/classes.rs b/crates/server/src/classes.rs new file mode 100644 index 0000000..5a2adf1 --- /dev/null +++ b/crates/server/src/classes.rs @@ -0,0 +1,1207 @@ +//! Classes as **application-level configuration** — the `ClassDirectory` and `Class` (issue #84). +//! +//! A class is a *racing category in the Director's address book*: a name and a little optional +//! metadata (where it came from, a reference id, a description). The model parallels the pilot +//! directory ([`PilotDirectory`](crate::pilots::PilotDirectory)): the Race Director maintains +//! their classes **once** at the application level (a persisted directory) and each event simply +//! builds a **selection** of which directory classes run at it (see +//! [`EventMeta::classes`](crate::events::EventMeta::classes)). Type a class in once, and every new +//! event just picks them. +//! +//! # Two pieces, mirroring pilots +//! +//! - **App-level directory (this module).** The [`ClassDirectory`] holds every configured +//! [`Class`] behind a lock and **persists** the user/Custom ones to +//! `/classes.json` (restored on boot; in-memory only when no data dir is +//! configured). On top of those it always seeds **9 code-defined built-in classes** (issue #84) — +//! the standard FPV classes (MultiGP / Five33 / FreedomSpec / Street League / UDL) — with **fixed +//! ids**, identical on every Director, so cross-event / cross-Director standings aggregate with +//! zero reconciliation. The built-ins are **read-only** (non-editable, non-deletable, like the +//! Mock timer) and are **never** written to `classes.json` (re-seeded on every boot). Users only +//! ever create [`Custom`](ClassSource::Custom) classes (full CRUD). +//! - **Per-event selection (`crate::events`).** Each [`EventMeta`](crate::events::EventMeta) +//! carries a `classes: Vec` of the directory classes that run at that event; new events +//! default to an **empty** selection. +//! +//! This is the **registry slice only** (issue #84's lineage): the directory of *which classes +//! exist* plus the per-event selection of *which run here*. The rounds / phase engine that a class +//! later drives is a separate concern and is not modelled here. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::pilots::OptionalEdit; +use crate::scope::ClassId; + +/// The file name (under the data dir) the class directory is persisted to (issue #84). +pub const CLASSES_FILE: &str = "classes.json"; + +/// The file name (under the data dir) the **hidden-set** is persisted to (hide/archive classes). +/// +/// A small sidecar holding just the [`ClassId`]s the RD has hidden from the per-event picker. It is +/// kept *separate* from `classes.json` deliberately: the built-in classes are re-seeded on every +/// boot and never written to `classes.json`, so a `hidden` flag stored *on* a class would be lost +/// on restart. Persisting the hidden ids on their own — and applying them **after** the re-seed — +/// makes a hidden built-in (or custom class) survive a Director restart. +pub const HIDDEN_CLASSES_FILE: &str = "hidden_classes.json"; + +/// Where a [`Class`] came from (issue #84). +/// +/// A small closed enum the directory records so the RD can tell a canonical built-in class (e.g. a +/// MultiGP spec class) from one they typed in themselves. Externally tagged (the default serde enum +/// representation) so it maps to a TS string union (`"MultiGP" | "Five33" | … | "Custom" | +/// "Other"`), exactly like [`VtxType`](crate::pilots::VtxType). The org variants name the standard +/// FPV racing leagues / spec bodies the built-in classes carry as their provenance (shown as a +/// badge). Defaults to [`Custom`](ClassSource::Custom) — a class the RD created by hand; users only +/// ever create `Custom` classes. [`Other`](ClassSource::Other) is the catch-all for any provenance +/// not enumerated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum ClassSource { + /// Imported from / aligned with **MultiGP** (a GQ / spec class). + MultiGP, + /// Aligned with **Five33** (flyfive33.com spec classes, e.g. Tiny Trainer). + Five33, + /// Aligned with the **Freedom Spec** open-spec class (freedomspec.com). + FreedomSpec, + /// Aligned with **Street League** (streetleague.io spec). + StreetLeague, + /// Aligned with the **Underground Drone League** (UDL, undergrounddroneleague.com). + UDL, + /// A **custom** class the RD created by hand (the default). The only source a user creates. + #[default] + Custom, + /// Any other provenance not enumerated above (catch-all). + Other, +} + +/// One class in the application-level directory (issue #84). +/// +/// The wire shape `GET /classes` returns and the on-disk shape `classes.json` persists: a stable +/// [`ClassId`] (auto-generated, never user-entered), a required `name`, a [`source`](Class::source) +/// provenance, and a little optional metadata. The optional fields are omitted from the wire when +/// unset (`skip_serializing_if`). Derives serde (its JSON *is* both the wire and the persisted form) +/// and `ts_rs::TS` so the frontend reads a generated `Class` type. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct Class { + /// The stable handle a per-event selection references and the API addresses + /// (`PUT /classes/{id}`). The same [`ClassId`] the scope/log layer uses — directory and scope + /// never disagree on what a class id is. + pub id: ClassId, + /// The class's **name** — the required display label (e.g. `"Open"`, `"Spec 5\""`). The + /// directory's one mandatory field; everything else is optional metadata. + pub name: String, + /// Where this class came from (see [`ClassSource`]). Defaults to + /// [`Custom`](ClassSource::Custom). + #[serde(default)] + pub source: ClassSource, + /// An optional **reference** id/handle into the source system (e.g. a MultiGP class id), if + /// recorded. Free-form. Omitted from the wire when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub reference: Option, + /// An optional free-text description / notes for the class. Omitted from the wire when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + /// Whether this is a **code-defined built-in** class (issue #84) — one of the canonical + /// standard FPV classes seeded into every directory with a fixed id, so cross-event / + /// cross-Director standings aggregate with zero reconciliation. Built-ins are **read-only**: + /// they cannot be edited or deleted, and are **not** persisted to `classes.json` (they are + /// re-seeded on every boot). A user-created [`Custom`](ClassSource::Custom) class is never a + /// built-in. Defaults to `false` and is omitted from the wire / disk when false, so a custom + /// class's JSON is unchanged. + #[serde(default, skip_serializing_if = "is_false")] + pub builtin: bool, + /// Whether this class is **hidden / archived** from the per-event class picker (hide/archive + /// classes). A pure **visibility preference**, not an edit: the RD can hide a class they don't + /// use (especially a built-in) so it stops cluttering the per-event picker, while it stays in + /// the directory and the main Classes view (where it can be un-hidden). Because built-ins are + /// re-seeded on every boot, this flag is **not** stored on the class itself — it is derived when + /// `GET /classes` is built from a persisted set of hidden ids (see [`HIDDEN_CLASSES_FILE`]), so + /// a hidden built-in survives a restart. Defaults to `false` and is omitted from the wire / disk + /// when false, so a class's persisted JSON is unchanged (and the hidden state never lands in + /// `classes.json`). + #[serde(default, skip_serializing_if = "is_false")] + pub hidden: bool, +} + +/// `skip_serializing_if` helper: the `builtin` flag is omitted from the wire/disk when false, so a +/// user/Custom class round-trips its original shape. +fn is_false(value: &bool) -> bool { + !*value +} + +/// One code-defined built-in class spec (issue #84): a fixed id + its canonical fields. Turned into +/// a [`Class`] (with `builtin = true`) by [`builtin_classes`]. +struct BuiltinClass { + /// The fixed, stable slug id — identical on every Director so standings aggregate. + id: &'static str, + /// The canonical display name. + name: &'static str, + /// The real org this class belongs to (shown as a source badge). + source: ClassSource, + /// A reference URL into the org's rules. + reference: &'static str, + /// A short canonical description. + description: &'static str, +} + +/// The 9 standard FPV racing classes seeded into **every** directory with **fixed ids** (issue +/// #84). Present on every Director identically so cross-event / cross-Director season standings +/// aggregate without reconciliation; carry their real org as [`source`](Class::source) (a badge); +/// are read-only (non-editable, non-deletable); and are **not** persisted to `classes.json`. +const BUILTIN_CLASSES: &[BuiltinClass] = &[ + BuiltinClass { + id: "mgp-open", + name: "Open Class", + source: ClassSource::MultiGP, + reference: "https://www.multigp.com/class-specifications/", + description: "Open/unlimited 5–6\" class — no motor/prop/electronics limits.", + }, + BuiltinClass { + id: "mgp-pro-spec", + name: "Pro Spec", + source: ClassSource::MultiGP, + reference: "https://www.multigp.com/prospec/", + description: "MultiGP's official 7\" spec class (Pro Spec frame, spec motor).", + }, + BuiltinClass { + id: "mgp-whoop", + name: "Whoop Class", + source: ClassSource::MultiGP, + reference: "https://www.multigp.com/class-specifications/", + description: "1S indoor micro whoop (65mm ducted).", + }, + BuiltinClass { + id: "mgp-micro", + name: "Micro Class", + source: ClassSource::MultiGP, + reference: "https://www.multigp.com/class-specifications/microclass/", + description: "3\" micro class (1404 motor, 3S).", + }, + BuiltinClass { + id: "five33-tiny-trainer", + name: "Tiny Trainer", + source: ClassSource::Five33, + reference: "https://flyfive33.com/pages/tt-spec", + description: "Five33 3\" spec (Tiny Trainer frame, 1404, 3S).", + }, + BuiltinClass { + id: "freedom-spec", + name: "Freedom Spec", + source: ClassSource::FreedomSpec, + reference: "https://freedomspec.com/", + description: "Open 5\" spec class with a standardized motor-RPM cap.", + }, + BuiltinClass { + id: "street-league", + name: "Street League", + source: ClassSource::StreetLeague, + reference: "https://streetleague.io/spec", + description: "7\" spec on firmware that equalizes motors via an RPM limiter.", + }, + BuiltinClass { + id: "udl-igniter", + name: "Igniter", + source: ClassSource::UDL, + reference: "https://undergrounddroneleague.com/igniter-legal-parts", + description: "Indoor 1S 75mm-ducted spec, approved-parts-only, RPM-limited.", + }, + BuiltinClass { + id: "udl-shrieker", + name: "Shrieker", + source: ClassSource::UDL, + reference: "https://undergrounddroneleague.com/drone-types", + description: "Fast 1S 65mm micro, near-open ruleset.", + }, +]; + +/// Build the 9 built-in [`Class`]es (issue #84) — the canonical, fixed-id, read-only classes seeded +/// into every directory. Each carries `builtin = true` and its real org as the source. +fn builtin_classes() -> Vec { + BUILTIN_CLASSES + .iter() + .map(|b| Class { + id: ClassId(b.id.to_string()), + name: b.name.to_string(), + source: b.source, + reference: Some(b.reference.to_string()), + description: Some(b.description.to_string()), + builtin: true, + // Visibility is layered on later from the persisted hidden-set, never seeded here. + hidden: false, + }) + .collect() +} + +/// Whether `id` is one of the fixed built-in class ids (issue #84) — built-ins are recognized by +/// their reserved id set, which is how the edit/delete guards and the persistence filter know not +/// to touch them. +fn is_builtin_id(id: &ClassId) -> bool { + BUILTIN_CLASSES.iter().any(|b| b.id == id.0) +} + +/// The body of `POST /classes` — the fields a caller supplies to create a class (issue #84). +/// +/// The `name` is required; everything else is optional. The **id is auto-generated** server-side (a +/// slug of the name + a short random suffix), never user-entered, mirroring `POST /pilots`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct CreateClassRequest { + /// The required name for the new class. + pub name: String, + /// The class's provenance, stored on [`Class::source`]. Defaults to + /// [`Custom`](ClassSource::Custom). + #[serde(default)] + pub source: ClassSource, + /// Optional reference id, stored on [`Class::reference`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub reference: Option, + /// Optional description, stored on [`Class::description`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, +} + +/// The body of `PUT /classes/{id}` — the editable fields of a class (issue #84). +/// +/// Every field is optional so a partial edit is a one-field body; the id is fixed (it is in the +/// path). A present `name` replaces it (a blank one is ignored — the name is required and never +/// cleared). A present `source` replaces the provenance. Each optional-metadata field is a +/// three-state [`OptionalEdit`]: **absent** leaves it unchanged ([`Keep`](OptionalEdit::Keep)), +/// present **`null`** clears it ([`Clear`](OptionalEdit::Clear)), and present **with a value** sets +/// it ([`Set`](OptionalEdit::Set)) — exactly the `UpdatePilotRequest` semantics. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct UpdateClassRequest { + /// A new name, or `None`/blank to leave it unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub name: Option, + /// A new provenance, or `None` to leave it unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub source: Option, + /// A new reference id: present value → set, present `null` → clear, absent → leave unchanged. + #[serde(default, skip_serializing_if = "OptionalEdit::is_keep")] + #[ts(optional = nullable)] + pub reference: OptionalEdit, + /// A new description (set / clear / leave-unchanged, like [`reference`](Self::reference)). + #[serde(default, skip_serializing_if = "OptionalEdit::is_keep")] + #[ts(optional = nullable)] + pub description: OptionalEdit, +} + +/// The body of `PUT /classes/{id}/hidden` — the new visibility for a class (hide/archive classes). +/// +/// A one-field body: `hidden: true` tucks the class away from the per-event picker, `false` brings +/// it back. Applies to **built-in** and custom classes alike (hiding is a visibility preference, not +/// an edit), so it is valid even on a read-only built-in. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct SetClassHiddenRequest { + /// The desired visibility: `true` → hidden (archived from the picker), `false` → visible. + pub hidden: bool, +} + +/// The application-level directory of all configured classes (issue #84). +/// +/// Maps each [`ClassId`] to its [`Class`]. The set is **persisted** to `/classes.json` +/// (restored on boot) so the RD's category list survives a Director restart; with no data dir +/// configured it is in-memory only. Cloning shares the one directory (`Arc>`), so it is +/// reached through the axum router state ([`EventRegistry`](crate::events::EventRegistry)) exactly +/// like the [`PilotDirectory`](crate::pilots::PilotDirectory). +#[derive(Clone)] +pub struct ClassDirectory { + inner: Arc>, +} + +/// The guarded interior: the class map, the hidden-set, and where the JSON files live. +struct Directory { + /// `ClassId → Class`. A `BTreeMap` so listing is deterministic (id order). The stored `Class` + /// values always carry `hidden = false`; visibility is layered on from [`hidden`](Self::hidden) + /// when a `Class` is *served* (see [`Directory::view`]), so the hidden state never has to live + /// on the class (and never leaks into `classes.json`). + classes: BTreeMap, + /// The set of **hidden** class ids (hide/archive classes) — built-in or custom. Persisted to + /// `/hidden_classes.json` and applied **after** the boot re-seed, so a hidden built-in + /// survives a restart. An id in here that no longer names a class is harmless (ignored on read). + hidden: BTreeSet, + /// Directory `classes.json` / `hidden_classes.json` are persisted under; `None` ⇒ in-memory + /// only (no data dir). + data_dir: Option, +} + +impl ClassDirectory { + /// Build a directory seeded with the 9 read-only built-in classes, restoring any user/Custom + /// classes from `/classes.json` when a data dir is given (issue #84). + /// + /// The built-ins are **always present** with their fixed ids — re-seeded on every boot, never + /// read from disk — so every Director carries the same canonical classes. When `data_dir` is + /// `Some` and a `classes.json` already exists, the saved **user** classes are restored on top + /// (an unreadable/corrupt file degrades to just the built-ins rather than failing to boot); any + /// stale entry that collides with a built-in id is ignored (the code-defined built-in wins). + /// When `data_dir` is `None` the directory is in-memory only (still seeded with the built-ins). + pub fn new(data_dir: Option) -> Result { + let mut classes = BTreeMap::new(); + + // Always seed the code-defined built-ins first (they are never read from disk). + for class in builtin_classes() { + classes.insert(class.id.clone(), class); + } + + let mut hidden = BTreeSet::new(); + if let Some(dir) = &data_dir { + std::fs::create_dir_all(dir).map_err(|e| { + ClassError::internal(format!("could not create data dir {}: {e}", dir.display())) + })?; + if let Some(restored) = read_persisted_classes(dir) { + for mut class in restored { + // A persisted entry can never be a built-in (we never write them); ignore any + // stale id collision and force the flag off for restored user classes. + if is_builtin_id(&class.id) { + continue; + } + class.builtin = false; + // Hidden state lives in the sidecar, never on the class — clear any stray flag. + class.hidden = false; + classes.insert(class.id.clone(), class); + } + } + // Restore the hidden-set **after** the re-seed so a hidden built-in survives a restart. + // (A bad/missing file degrades to "nothing hidden" rather than failing to boot.) + if let Some(ids) = read_hidden_classes(dir) { + hidden = ids; + } + } + + Ok(Self { + inner: Arc::new(RwLock::new(Directory { + classes, + hidden, + data_dir, + })), + }) + } + + /// Every class in id order — the `GET /classes` body. Each served [`Class`] carries its + /// `hidden` flag derived from the persisted hidden-set (hide/archive classes), so the frontend + /// can mark hidden classes in the main view and filter them out of the per-event picker. + pub fn list(&self) -> Vec { + let dir = self.read(); + dir.classes.values().map(|c| dir.view(c)).collect() + } + + /// Whether a class with `id` exists — the per-event selection validates each id through this. + /// (A *hidden* class still exists; hiding only affects what the picker *offers*, never the + /// validity of an id already selected on an event.) + pub fn exists(&self, id: &ClassId) -> bool { + self.read().classes.contains_key(id) + } + + /// The [`Class`] for `id` (with its `hidden` flag applied), or `None`. + pub fn get(&self, id: &ClassId) -> Option { + let dir = self.read(); + dir.classes.get(id).map(|c| dir.view(c)) + } + + /// Hide or un-hide a class (hide/archive classes): add or remove `id` from the persisted + /// hidden-set and return the updated [`Class`] (with its fresh `hidden` flag). + /// + /// Hiding is a **visibility preference**, not an edit — so it is allowed for **built-in** + /// classes too (it is *not* a [`ReadOnly`](ClassErrorKind::ReadOnly) violation): the whole + /// point is to let the RD tuck away the standard built-ins they don't run. An unknown id is a + /// 404 ([`NotFound`](ClassErrorKind::NotFound)). The hidden-set is **persisted** on success, so + /// the choice survives a restart — including the boot re-seed of the built-ins. + pub fn set_hidden(&self, id: &ClassId, hidden: bool) -> Result { + let mut dir = self.write(); + if !dir.classes.contains_key(id) { + return Err(ClassError::not_found(format!( + "no class with id {:?}", + id.0 + ))); + } + let changed = if hidden { + dir.hidden.insert(id.clone()) + } else { + dir.hidden.remove(id) + }; + if changed { + dir.persist_hidden()?; + } + let class = dir.classes.get(id).expect("checked above"); + Ok(dir.view(class)) + } + + /// Create a class from a [`CreateClassRequest`], returning it (issue #84). + /// + /// The **id is auto-generated** — a slug of the `name` + a short random suffix — so it is + /// unique and never user-entered. The optional metadata is stored verbatim (trimmed, with a + /// blank treated as unset). The directory is **persisted** on success. + pub fn create(&self, request: &CreateClassRequest) -> Result { + let name = request.name.trim(); + if name.is_empty() { + return Err(ClassError::invalid("a class name is required")); + } + let mut dir = self.write(); + let id = loop { + let candidate = ClassId(format!("{}-{}", slugify(name), short_suffix())); + if !is_builtin_id(&candidate) && !dir.classes.contains_key(&candidate) { + break candidate; + } + }; + let class = Class { + id: id.clone(), + name: name.to_string(), + source: request.source, + reference: normalize_optional(&request.reference), + description: normalize_optional(&request.description), + builtin: false, + // A freshly-created class is always visible; the RD hides it later if they choose. + hidden: false, + }; + dir.classes.insert(id, class.clone()); + dir.persist()?; + Ok(class) + } + + /// Edit a class's fields (issue #84), returning the updated [`Class`]. + /// + /// A present `name` replaces it (a blank one is ignored — the name is required). A present + /// `source` replaces the provenance. Each optional metadata field is a three-way edit: absent → + /// unchanged; present `Some(value)` → set (trimmed; a blank string clears it); present `null` → + /// cleared. A **built-in** class is read-only — editing one is rejected (issue #84). An unknown + /// id is a [`ClassError`]. The directory is **persisted** on success. + pub fn update(&self, id: &ClassId, request: &UpdateClassRequest) -> Result { + if is_builtin_id(id) { + return Err(ClassError::read_only(format!( + "the built-in class {:?} is read-only and cannot be edited", + id.0 + ))); + } + let mut dir = self.write(); + let class = dir + .classes + .get_mut(id) + .ok_or_else(|| ClassError::not_found(format!("no class with id {:?}", id.0)))?; + if let Some(name) = &request.name { + let trimmed = name.trim(); + if !trimmed.is_empty() { + class.name = trimmed.to_string(); + } + } + if let Some(source) = request.source { + class.source = source; + } + apply_string_edit(&mut class.reference, &request.reference); + apply_string_edit(&mut class.description, &request.description); + let updated = class.clone(); + dir.persist()?; + // Re-apply the served `hidden` flag (an edit never changes visibility). + Ok(dir.view(&updated)) + } + + /// Delete a class (issue #84). A **built-in** class is read-only and cannot be deleted (it is + /// always present); attempting to is rejected — mirroring the Mock timer's protected delete. An + /// unknown id is a [`ClassError`]. The directory is **persisted** on success. (Removing a class + /// does not touch any event selection that still names it; a stale selection id is harmless — + /// see the events module.) + pub fn delete(&self, id: &ClassId) -> Result<(), ClassError> { + if is_builtin_id(id) { + return Err(ClassError::read_only(format!( + "the built-in class {:?} is read-only and cannot be deleted", + id.0 + ))); + } + let mut dir = self.write(); + if dir.classes.remove(id).is_none() { + return Err(ClassError::not_found(format!( + "no class with id {:?}", + id.0 + ))); + } + dir.persist()?; + // A deleted class drops out of the hidden-set too (so a later recreate isn't silently + // hidden by a stale id, and the sidecar doesn't accumulate dangling ids). + if dir.hidden.remove(id) { + dir.persist_hidden()?; + } + Ok(()) + } + + fn read(&self) -> std::sync::RwLockReadGuard<'_, Directory> { + self.inner.read().expect("class directory lock poisoned") + } + + fn write(&self) -> std::sync::RwLockWriteGuard<'_, Directory> { + self.inner.write().expect("class directory lock poisoned") + } +} + +impl Directory { + /// A served view of `class`: the stored class with its `hidden` flag set from the hidden-set + /// (hide/archive classes). The stored `Class` always has `hidden = false`; this is the one place + /// visibility is layered on, so `GET /classes` / `get` / `set_hidden` all report it consistently + /// and the hidden state never has to live on the stored class. + fn view(&self, class: &Class) -> Class { + let mut out = class.clone(); + out.hidden = self.hidden.contains(&class.id); + out + } + + /// Persist the **user/Custom** classes to `/classes.json` (issue #84), a no-op with no + /// data dir. The code-defined built-ins are **never** written — they are re-seeded on every boot + /// — so the persisted file holds only the classes the RD created. + fn persist(&self) -> Result<(), ClassError> { + let Some(dir) = &self.data_dir else { + return Ok(()); + }; + let classes: Vec<&Class> = self + .classes + .values() + .filter(|c| !c.builtin && !is_builtin_id(&c.id)) + .collect(); + let json = serde_json::to_string_pretty(&classes) + .map_err(|e| ClassError::internal(format!("could not serialize classes: {e}")))?; + std::fs::write(classes_path(dir), json) + .map_err(|e| ClassError::internal(format!("could not persist classes: {e}"))) + } + + /// Persist the **hidden-set** to `/hidden_classes.json` (hide/archive classes), a + /// no-op with no data dir. Written as a plain JSON array of the hidden [`ClassId`]s — a tiny + /// sidecar kept separate from `classes.json` so it can record hidden *built-in* ids (which never + /// go in `classes.json`) and be applied **after** the boot re-seed. + fn persist_hidden(&self) -> Result<(), ClassError> { + let Some(dir) = &self.data_dir else { + return Ok(()); + }; + let ids: Vec<&ClassId> = self.hidden.iter().collect(); + let json = serde_json::to_string_pretty(&ids).map_err(|e| { + ClassError::internal(format!("could not serialize hidden classes: {e}")) + })?; + std::fs::write(hidden_classes_path(dir), json) + .map_err(|e| ClassError::internal(format!("could not persist hidden classes: {e}"))) + } +} + +/// Apply an optional string edit to a stored field: [`Keep`](OptionalEdit::Keep) → unchanged; +/// [`Clear`](OptionalEdit::Clear) → cleared; [`Set`](OptionalEdit::Set) → set (trimmed, with a blank +/// value treated as a clear). +fn apply_string_edit(field: &mut Option, edit: &OptionalEdit) { + match edit { + OptionalEdit::Keep => {} + OptionalEdit::Clear => *field = None, + OptionalEdit::Set(value) => { + *field = Some(value.trim().to_string()).filter(|s| !s.is_empty()); + } + } +} + +/// The file the class set is persisted to under `dir`: `/classes.json`. +fn classes_path(dir: &Path) -> PathBuf { + dir.join(CLASSES_FILE) +} + +/// Read the persisted classes from `/classes.json`, or `None` if absent/unreadable/corrupt. +/// A bad file degrades to "no persisted classes" so the Director still boots. +fn read_persisted_classes(dir: &Path) -> Option> { + let raw = std::fs::read_to_string(classes_path(dir)).ok()?; + serde_json::from_str(&raw).ok() +} + +/// The file the hidden-set is persisted to under `dir`: `/hidden_classes.json`. +fn hidden_classes_path(dir: &Path) -> PathBuf { + dir.join(HIDDEN_CLASSES_FILE) +} + +/// Read the persisted hidden-set from `/hidden_classes.json`, or `None` if +/// absent/unreadable/corrupt (degrades to "nothing hidden" so the Director still boots). +fn read_hidden_classes(dir: &Path) -> Option> { + let raw = std::fs::read_to_string(hidden_classes_path(dir)).ok()?; + serde_json::from_str(&raw).ok() +} + +/// An error mutating the class directory (a persistence failure, an unknown id, a missing name). +/// Carries a [`ClassErrorKind`] so the HTTP layer can map a *validation* failure to `400` and an +/// *unknown id* to `404`. +#[derive(Debug, Clone)] +pub struct ClassError { + /// What kind of failure this is (drives the HTTP status the handler picks). + pub kind: ClassErrorKind, + /// A human-readable message. + pub message: String, +} + +/// The class of a [`ClassError`], so a handler can pick the right status (issue #84). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClassErrorKind { + /// A bad request value (missing name, …) → 400. + Invalid, + /// An attempt to edit/delete a read-only **built-in** class → 400 (rejected, like the Mock + /// timer's protected delete). + ReadOnly, + /// The addressed class id does not exist → 404. + NotFound, + /// A server-side persistence failure → 500. + Internal, +} + +impl ClassError { + /// A validation / bad-request error (HTTP 400). + fn invalid(message: impl Into) -> Self { + Self { + kind: ClassErrorKind::Invalid, + message: message.into(), + } + } + + /// A read-only error: an attempt to edit/delete a built-in class (HTTP 400). + fn read_only(message: impl Into) -> Self { + Self { + kind: ClassErrorKind::ReadOnly, + message: message.into(), + } + } + + /// An unknown-id error (HTTP 404). + fn not_found(message: impl Into) -> Self { + Self { + kind: ClassErrorKind::NotFound, + message: message.into(), + } + } + + /// An internal / persistence error (HTTP 500). + fn internal(message: impl Into) -> Self { + Self { + kind: ClassErrorKind::Internal, + message: message.into(), + } + } +} + +impl std::fmt::Display for ClassError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "class directory error: {}", self.message) + } +} + +impl std::error::Error for ClassError {} + +/// Trim an optional field, treating a blank/whitespace-only value as **unset** (`None`). +fn normalize_optional(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +/// Slugify a name into an id-friendly stem (the same rule as the pilot/event registries): +/// lowercase ASCII alphanumerics kept, every other run collapsed to a single `-`, trimmed of +/// dashes; an empty/symbol-only name yields `class`. +fn slugify(name: &str) -> String { + let mut slug = String::new(); + let mut prev_dash = false; + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + prev_dash = false; + } else if !prev_dash { + slug.push('-'); + prev_dash = true; + } + } + let trimmed = slug.trim_matches('-'); + if trimmed.is_empty() { + "class".to_string() + } else { + trimmed.to_string() + } +} + +/// A short random lowercase-alphanumeric suffix making an auto-generated id unique (same source as +/// the pilot/event registries — the OS CSPRNG). +fn short_suffix() -> String { + const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + let mut bytes = [0u8; 6]; + getrandom::fill(&mut bytes).expect("OS CSPRNG available"); + bytes + .iter() + .map(|b| ALPHABET[(*b as usize) % ALPHABET.len()] as char) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn req(name: &str) -> CreateClassRequest { + CreateClassRequest { + name: name.to_string(), + ..Default::default() + } + } + + /// The number of code-defined built-in classes seeded into every directory. + const BUILTIN_COUNT: usize = 9; + + /// The non-built-in (user/Custom) classes in id order. + fn user_classes(dir: &ClassDirectory) -> Vec { + dir.list().into_iter().filter(|c| !c.builtin).collect() + } + + #[test] + fn a_fresh_directory_holds_only_the_built_ins() { + let dir = ClassDirectory::new(None).unwrap(); + let list = dir.list(); + assert_eq!(list.len(), BUILTIN_COUNT); + assert!(list.iter().all(|c| c.builtin)); + assert!(user_classes(&dir).is_empty()); + } + + #[test] + fn the_nine_built_ins_are_present_with_fixed_ids_orgs_and_metadata() { + let dir = ClassDirectory::new(None).unwrap(); + // Every fixed id is present, flagged built-in, and carries its real org + reference + desc. + for spec in BUILTIN_CLASSES { + let id = ClassId(spec.id.to_string()); + let got = dir + .get(&id) + .unwrap_or_else(|| panic!("missing {}", spec.id)); + assert!(got.builtin, "{} must be flagged built-in", spec.id); + assert_eq!(got.name, spec.name); + assert_eq!(got.source, spec.source); + assert_eq!(got.reference.as_deref(), Some(spec.reference)); + assert_eq!(got.description.as_deref(), Some(spec.description)); + } + // The expected fixed-id set, exactly. + let ids: std::collections::BTreeSet<_> = dir.list().into_iter().map(|c| c.id.0).collect(); + for expected in [ + "mgp-open", + "mgp-pro-spec", + "mgp-whoop", + "mgp-micro", + "five33-tiny-trainer", + "freedom-spec", + "street-league", + "udl-igniter", + "udl-shrieker", + ] { + assert!(ids.contains(expected), "missing built-in id {expected}"); + } + // The org sources are the expected spread. + assert_eq!( + dir.get(&ClassId("mgp-open".into())).unwrap().source, + ClassSource::MultiGP + ); + assert_eq!( + dir.get(&ClassId("five33-tiny-trainer".into())) + .unwrap() + .source, + ClassSource::Five33 + ); + assert_eq!( + dir.get(&ClassId("freedom-spec".into())).unwrap().source, + ClassSource::FreedomSpec + ); + assert_eq!( + dir.get(&ClassId("street-league".into())).unwrap().source, + ClassSource::StreetLeague + ); + assert_eq!( + dir.get(&ClassId("udl-igniter".into())).unwrap().source, + ClassSource::UDL + ); + } + + #[test] + fn a_built_in_cannot_be_edited_or_deleted() { + let dir = ClassDirectory::new(None).unwrap(); + let id = ClassId("mgp-open".into()); + + let edit = dir.update( + &id, + &UpdateClassRequest { + name: Some("Renamed".into()), + ..Default::default() + }, + ); + let err = edit.unwrap_err(); + assert_eq!(err.kind, ClassErrorKind::ReadOnly); + // The built-in is untouched. + assert_eq!(dir.get(&id).unwrap().name, "Open Class"); + + let del = dir.delete(&id); + assert_eq!(del.unwrap_err().kind, ClassErrorKind::ReadOnly); + assert!(dir.exists(&id)); + } + + #[test] + fn class_source_defaults_to_custom() { + assert_eq!(ClassSource::default(), ClassSource::Custom); + let dir = ClassDirectory::new(None).unwrap(); + let class = dir.create(&req("Open")).unwrap(); + assert_eq!(class.source, ClassSource::Custom); + } + + #[test] + fn create_auto_generates_a_unique_slug_id() { + let dir = ClassDirectory::new(None).unwrap(); + let a = dir.create(&req("Spec 5\"!")).unwrap(); + let b = dir.create(&req("Spec 5\"!")).unwrap(); + assert!(a.id.0.starts_with("spec-5-")); + assert_ne!(a.id, b.id); + assert_eq!(a.name, "Spec 5\"!"); + let ids: Vec<_> = dir.list().into_iter().map(|c| c.id).collect(); + assert!(ids.contains(&a.id) && ids.contains(&b.id)); + } + + #[test] + fn create_requires_a_name() { + let dir = ClassDirectory::new(None).unwrap(); + let err = dir.create(&req(" ")).unwrap_err(); + assert_eq!(err.kind, ClassErrorKind::Invalid); + assert!(user_classes(&dir).is_empty()); + } + + #[test] + fn create_stores_source_and_optional_metadata() { + let dir = ClassDirectory::new(None).unwrap(); + let class = dir + .create(&CreateClassRequest { + name: "Open".to_string(), + source: ClassSource::MultiGP, + reference: Some(" mgp-open ".to_string()), // trimmed + description: Some(" ".to_string()), // blank → unset + }) + .unwrap(); + assert_eq!(class.source, ClassSource::MultiGP); + assert_eq!(class.reference.as_deref(), Some("mgp-open")); + assert_eq!(class.description, None); + } + + #[test] + fn update_edits_name_source_and_optional_fields_with_clear_semantics() { + let dir = ClassDirectory::new(None).unwrap(); + let created = dir + .create(&CreateClassRequest { + name: "Old".to_string(), + source: ClassSource::Custom, + reference: Some("ref-1".to_string()), + description: Some("desc".to_string()), + }) + .unwrap(); + + // Set name + source, clear reference, leave description unchanged (absent). + let updated = dir + .update( + &created.id, + &UpdateClassRequest { + name: Some("New".to_string()), + source: Some(ClassSource::MultiGP), + reference: OptionalEdit::Clear, // explicit clear + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(updated.name, "New"); + assert_eq!(updated.source, ClassSource::MultiGP); + assert_eq!(updated.reference, None); + assert_eq!(updated.description.as_deref(), Some("desc")); // absent → unchanged + + // A blank name is ignored (required field never cleared). + let again = dir + .update( + &created.id, + &UpdateClassRequest { + name: Some(" ".to_string()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(again.name, "New"); + } + + #[test] + fn optional_edit_deserializes_the_three_wire_cases() { + // A present value → Set; a present `null` → Clear; an absent field → Keep (the default). + let set: UpdateClassRequest = serde_json::from_str(r#"{"reference":"X"}"#).unwrap(); + assert_eq!(set.reference, OptionalEdit::Set("X".to_string())); + + let clear: UpdateClassRequest = serde_json::from_str(r#"{"reference":null}"#).unwrap(); + assert_eq!(clear.reference, OptionalEdit::Clear); + + let absent: UpdateClassRequest = serde_json::from_str("{}").unwrap(); + assert_eq!(absent.reference, OptionalEdit::Keep); + } + + /// Round-trip a JSON body through `update` (persisted to `classes.json`) to prove a wire `null` + /// clears `reference`/`description` — the [`OptionalEdit`] behavior — and that the clear + /// survives a restart. + #[test] + fn wire_null_clears_reference_and_description_through_persistence() { + let data_dir = + std::env::temp_dir().join(format!("gridfpv-classes-clear-{}", short_suffix())); + { + let dir = ClassDirectory::new(Some(data_dir.clone())).unwrap(); + let created = dir + .create(&CreateClassRequest { + name: "Clearable".to_string(), + source: ClassSource::Other, + reference: Some("ref-x".to_string()), + description: Some("a description".to_string()), + }) + .unwrap(); + + // `{"reference":null,"description":null}` → both cleared; source absent → unchanged. + let clear_body: UpdateClassRequest = + serde_json::from_str(r#"{"reference":null,"description":null}"#).unwrap(); + let cleared = dir.update(&created.id, &clear_body).unwrap(); + assert_eq!(cleared.reference, None, "wire null must clear reference"); + assert_eq!( + cleared.description, None, + "wire null must clear description" + ); + assert_eq!(cleared.source, ClassSource::Other); // absent → unchanged + + // The clear survives a restart (it was persisted to classes.json, not just in mem). + let reopened = ClassDirectory::new(Some(data_dir.clone())).unwrap(); + let got = reopened.get(&created.id).unwrap(); + assert_eq!(got.reference, None); + assert_eq!(got.description, None); + assert_eq!(got.source, ClassSource::Other); + } + std::fs::remove_dir_all(&data_dir).ok(); + } + + #[test] + fn update_and_delete_reject_unknown_ids() { + let dir = ClassDirectory::new(None).unwrap(); + let unknown = ClassId("nope".into()); + assert!( + dir.update( + &unknown, + &UpdateClassRequest { + name: Some("X".into()), + ..Default::default() + }, + ) + .is_err() + ); + assert!(dir.delete(&unknown).is_err()); + } + + #[test] + fn delete_removes_a_created_class() { + let dir = ClassDirectory::new(None).unwrap(); + let created = dir.create(&req("Temp")).unwrap(); + assert!(dir.exists(&created.id)); + dir.delete(&created.id).unwrap(); + assert!(!dir.exists(&created.id)); + assert!(dir.delete(&created.id).is_err()); + } + + #[test] + fn classes_persist_across_a_restart_with_a_data_dir() { + let data_dir = + std::env::temp_dir().join(format!("gridfpv-classes-test-{}", short_suffix())); + { + let dir = ClassDirectory::new(Some(data_dir.clone())).unwrap(); + let created = dir + .create(&CreateClassRequest { + name: "Persisted".to_string(), + source: ClassSource::MultiGP, + reference: Some("mgp-7".to_string()), + description: Some("the persisted class".to_string()), + }) + .unwrap(); + + let reopened = ClassDirectory::new(Some(data_dir.clone())).unwrap(); + let got = reopened.get(&created.id).unwrap(); + assert_eq!(got.name, "Persisted"); + assert_eq!(got.source, ClassSource::MultiGP); + assert_eq!(got.reference.as_deref(), Some("mgp-7")); + assert_eq!(got.description.as_deref(), Some("the persisted class")); + } + std::fs::remove_dir_all(&data_dir).ok(); + } + + #[test] + fn a_corrupt_classes_file_degrades_to_just_the_built_ins() { + let data_dir = std::env::temp_dir().join(format!("gridfpv-classes-bad-{}", short_suffix())); + std::fs::create_dir_all(&data_dir).unwrap(); + std::fs::write(classes_path(&data_dir), b"not json at all").unwrap(); + let dir = ClassDirectory::new(Some(data_dir.clone())).unwrap(); + // The built-ins are still seeded; only user classes are missing. + assert_eq!(dir.list().len(), BUILTIN_COUNT); + assert!(user_classes(&dir).is_empty()); + std::fs::remove_dir_all(&data_dir).ok(); + } + + #[test] + fn built_ins_are_not_written_to_classes_json_only_user_classes_are() { + let data_dir = + std::env::temp_dir().join(format!("gridfpv-classes-persist-{}", short_suffix())); + { + let dir = ClassDirectory::new(Some(data_dir.clone())).unwrap(); + // A user/Custom class is full CRUD + persists… + let created = dir + .create(&CreateClassRequest { + name: "House Spec".to_string(), + ..Default::default() + }) + .unwrap(); + assert!(!created.builtin); + assert_eq!(created.source, ClassSource::Custom); + + // …but the on-disk file holds ONLY the user class — never a built-in. + let raw = std::fs::read_to_string(classes_path(&data_dir)).unwrap(); + let on_disk: Vec = serde_json::from_str(&raw).unwrap(); + assert_eq!(on_disk.len(), 1, "classes.json holds only the user class"); + assert_eq!(on_disk[0].id, created.id); + assert!(!on_disk[0].builtin); + // None of the fixed built-in ids leaked to disk. + for spec in BUILTIN_CLASSES { + assert!( + !raw.contains(&format!("\"{}\"", spec.id)), + "built-in id {} must not be persisted", + spec.id + ); + } + + // A reopen re-seeds the built-ins and restores the one user class. + let reopened = ClassDirectory::new(Some(data_dir.clone())).unwrap(); + assert_eq!(reopened.list().len(), BUILTIN_COUNT + 1); + let got = reopened.get(&created.id).unwrap(); + assert_eq!(got.name, "House Spec"); + assert!(!got.builtin); + // The built-ins are still all present. + assert!(reopened.get(&ClassId("mgp-open".into())).unwrap().builtin); + } + std::fs::remove_dir_all(&data_dir).ok(); + } + + // ── hide / archive classes ────────────────────────────────────────────── + + #[test] + fn classes_default_to_visible_and_set_hidden_toggles_the_flag() { + let dir = ClassDirectory::new(None).unwrap(); + let created = dir.create(&req("House Spec")).unwrap(); + // Fresh classes (and built-ins) are visible by default. + assert!(!created.hidden); + assert!(!dir.get(&ClassId("mgp-open".into())).unwrap().hidden); + + // Hide it, then un-hide it — the served flag and the list both reflect the change. + let hidden = dir.set_hidden(&created.id, true).unwrap(); + assert!(hidden.hidden); + assert!(dir.get(&created.id).unwrap().hidden); + assert!( + dir.list() + .iter() + .find(|c| c.id == created.id) + .unwrap() + .hidden + ); + + let shown = dir.set_hidden(&created.id, false).unwrap(); + assert!(!shown.hidden); + assert!(!dir.get(&created.id).unwrap().hidden); + } + + #[test] + fn hiding_a_class_is_not_a_read_only_edit_so_built_ins_can_be_hidden() { + let dir = ClassDirectory::new(None).unwrap(); + let id = ClassId("mgp-open".into()); + // A built-in cannot be edited/deleted (ReadOnly), but it CAN be hidden — visibility is a + // preference, not an edit. + let hidden = dir.set_hidden(&id, true).unwrap(); + assert!(hidden.hidden); + assert!(hidden.builtin, "still a built-in, just hidden"); + assert!(dir.get(&id).unwrap().hidden); + // Editing/deleting it is still rejected. + assert_eq!( + dir.update( + &id, + &UpdateClassRequest { + name: Some("X".into()), + ..Default::default() + } + ) + .unwrap_err() + .kind, + ClassErrorKind::ReadOnly + ); + assert_eq!(dir.delete(&id).unwrap_err().kind, ClassErrorKind::ReadOnly); + } + + #[test] + fn set_hidden_rejects_an_unknown_id() { + let dir = ClassDirectory::new(None).unwrap(); + let err = dir.set_hidden(&ClassId("nope".into()), true).unwrap_err(); + assert_eq!(err.kind, ClassErrorKind::NotFound); + } + + #[test] + fn a_hidden_built_in_persists_across_a_restart_surviving_the_reseed() { + let data_dir = + std::env::temp_dir().join(format!("gridfpv-classes-hide-{}", short_suffix())); + { + let dir = ClassDirectory::new(Some(data_dir.clone())).unwrap(); + let builtin = ClassId("mgp-open".into()); + let custom = dir.create(&req("House Spec")).unwrap(); + dir.set_hidden(&builtin, true).unwrap(); + dir.set_hidden(&custom.id, true).unwrap(); + + // The sidecar holds the hidden ids; classes.json never carries the built-in id or a + // `hidden` flag. + let hidden_raw = std::fs::read_to_string(hidden_classes_path(&data_dir)).unwrap(); + assert!(hidden_raw.contains("mgp-open")); + let classes_raw = std::fs::read_to_string(classes_path(&data_dir)).unwrap(); + assert!( + !classes_raw.contains("mgp-open"), + "built-in id must not leak into classes.json" + ); + assert!( + !classes_raw.contains("hidden"), + "hidden state must not be persisted on the class in classes.json" + ); + + // Reopen: the built-ins are re-seeded, yet the hidden built-in (and custom) stay hidden. + let reopened = ClassDirectory::new(Some(data_dir.clone())).unwrap(); + let bi = reopened.get(&builtin).unwrap(); + assert!(bi.builtin, "re-seeded built-in present"); + assert!(bi.hidden, "hidden built-in survives the re-seed"); + assert!(reopened.get(&custom.id).unwrap().hidden); + + // Un-hide the built-in and confirm it sticks across another restart. + reopened.set_hidden(&builtin, false).unwrap(); + let again = ClassDirectory::new(Some(data_dir.clone())).unwrap(); + assert!(!again.get(&builtin).unwrap().hidden); + } + std::fs::remove_dir_all(&data_dir).ok(); + } + + #[test] + fn deleting_a_class_drops_it_from_the_hidden_set() { + let data_dir = + std::env::temp_dir().join(format!("gridfpv-classes-hide-del-{}", short_suffix())); + { + let dir = ClassDirectory::new(Some(data_dir.clone())).unwrap(); + let created = dir.create(&req("Temp")).unwrap(); + dir.set_hidden(&created.id, true).unwrap(); + dir.delete(&created.id).unwrap(); + // The id is gone from the sidecar, so a same-named recreate is not silently hidden. + let raw = std::fs::read_to_string(hidden_classes_path(&data_dir)).unwrap(); + assert!(!raw.contains(&created.id.0)); + } + std::fs::remove_dir_all(&data_dir).ok(); + } +} diff --git a/crates/server/src/control.rs b/crates/server/src/control.rs new file mode 100644 index 0000000..fa54cd9 --- /dev/null +++ b/crates/server/src/control.rs @@ -0,0 +1,539 @@ +//! 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, ClassId, CompetitorRef, HeatId, LogRef, Penalty, ProtestOutcome, RoundId, 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), [`Start`](Command::Start), +/// [`Finalize`](Command::Finalize), [`Advance`](Command::Advance), the off-ramps +/// [`Revert`](Command::Revert), [`Abort`](Command::Abort), +/// [`Restart`](Command::Restart), [`Discard`](Command::Discard), and the runtime-clock +/// **overrides** [`SkipCountdown`](Command::SkipCountdown) / [`ForceEnd`](Command::ForceEnd). +/// Each requests the matching [`HeatTransition`](gridfpv_events::HeatTransition); the engine +/// validates it against the heat's current state (race-engine.html §2). The ordinary +/// `Armed → Running` and `Running → Unofficial` transitions are appended by the Director's +/// **runtime clock** (heat-lifecycle Slice 2), not by a command — `SkipCountdown`/`ForceEnd` +/// are the manual overrides for when the clock must be bypassed. +/// - **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 corrections +/// ([`VoidDetection`](Command::VoidDetection), [`InsertLap`](Command::InsertLap), +/// [`AdjustLap`](Command::AdjustLap), [`SplitLap`](Command::SplitLap), +/// [`VoidHeat`](Command::VoidHeat), [`ApplyPenalty`](Command::ApplyPenalty), +/// [`ReverseRuling`](Command::ReverseRuling)), each requesting the corresponding +/// marshaling event the projection/scorer 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, + }, + /// Start the heat — arm it (open the gate to detections) and run the start procedure. The + /// Director's runtime clock then auto-advances the heat to `Running` after the logged start + /// delay. (Renamed from the former `Arm` in the heat-lifecycle command collapse, Slice 2.) + Start { + /// The heat to transition. + heat: HeatId, + }, + /// **Override:** force the heat `Armed → Running` immediately, skipping the start countdown — + /// the race-day escape hatch when the runtime's auto-start can't be trusted. Records the same + /// `Running` transition the auto-start would (Slice 2). + SkipCountdown { + /// The heat to transition. + heat: HeatId, + }, + /// **Override:** force the heat `Running → Unofficial` now — call the race when the runtime's + /// completion clock must be bypassed. Records the same `Finished` transition the auto-complete + /// would (Slice 2). + ForceEnd { + /// The heat to transition. + heat: HeatId, + }, + /// Finalize the heat — lock in the result (Unofficial → Final). + Finalize { + /// The heat to transition. + heat: HeatId, + }, + /// Advance the finalized heat — hand its result to the format generator. + Advance { + /// The heat to transition. + heat: HeatId, + }, + /// Revert a finalized heat — re-open its result for correction (Final → Unofficial). + Revert { + /// The heat to transition. + heat: HeatId, + }, + /// Abort the heat — abandon it before finalizing (an off-ramp). + Abort { + /// The heat to transition. + heat: HeatId, + }, + /// Restart a committed heat — reset to `Scheduled` for a re-run (the RD re-Stages). + Restart { + /// The heat to transition. + heat: HeatId, + }, + /// Discard a finalized heat — drop its result for a re-run. + Discard { + /// The heat to transition. + heat: HeatId, + }, + + // --- Live-control selection --- + /// **Manually select the current heat** in Live control (the RD's explicit "show/control + /// *this* heat"). Validates the heat exists in the event and appends an + /// [`Event::CurrentHeatSelected`](gridfpv_events::Event::CurrentHeatSelected) — the live + /// `current_heat` derivation then follows the RD's choice. Not a heat-loop transition: it + /// only moves Live control's focus (the sheet/clock/leaderboard + the transition buttons + /// target the chosen heat); it does not change the heat's state. + SetCurrentHeat { + /// The heat to bring into focus — one already scheduled in the event. + heat: HeatId, + }, + + // --- Scheduling --- + /// Create a heat with its lineup (`Event::HeatScheduled`). Additively carries the + /// class/round the heat runs in, the per-pilot frequency assignment, and an optional + /// human `label`; all are optional and default-absent, so the free-text NewHeat path + /// (which assigns none of them) is unchanged on the wire. + ScheduleHeat { + /// The id the new heat will carry. + heat: HeatId, + /// The competitors in the heat, in lineup order. + lineup: Vec, + /// The class this heat runs in, when the scheduler assigns one. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + class: Option, + /// The round within the class's schedule, when the scheduler assigns one. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + round: Option, + /// Per-pilot frequency assignment in raw MHz (e.g. `5800`); empty when none is + /// assigned (a sim race, or the free-text path). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + frequencies: Vec<(CompetitorRef, u16)>, + /// An **optional human label** for a manually-built heat. When set it becomes the + /// heat's display name everywhere (overriding the derived "‹Round› Heat N" / tier + /// convention); `None` (the default / the generator path) keeps the auto-name. + /// Threaded straight into the emitted [`Event::HeatScheduled`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + label: Option, + }, + + /// **Fill a round** (race redesign Slice 3a) — the round-driven engine's one command. + /// Build the round's format generator from the eligible classes' membership (the field) + /// and the round's completed heats read back from the log, then schedule the **next** + /// heat the generator emits — a [`Event::HeatScheduled`] tagged with `round` (and + /// `class` when the round is single-class), lineup from the generator's plan. + /// + /// The advance closes through the log: once the scheduled heat is driven to `Score`, the + /// next `FillRound` rebuilds the generator, sees the new completed heat, and emits the + /// following one — including the qualifying→bracket carry when the round seeds + /// [`FromRanking`](crate::events::SeedingRule::FromRanking). A round whose generator has + /// no more heats is **complete** — a successful ack, not an error. + FillRound { + /// The round to fill — one of the event's [`rounds`](crate::events::EventMeta::rounds). + round: RoundId, + /// How much of the round to fill in this one command (#216): + /// + /// - [`FillMode::Next`] (the default — wire-compatible with the original `{ round }` + /// shape) schedules the **single** next heat the generator emits. + /// - [`FillMode::All`] loops the generator — append a heat, re-fold the round's state, + /// draw the next — until the round reports **complete** (no more heats producible now), + /// filling a whole deterministic round in one round-trip. + /// + /// Either way an already-complete round (or one whose outstanding heat must be scored + /// first) appends nothing and acks a typed ok — `All` is just `Next` iterated to that + /// terminal state, so it stays idempotent on re-run. + #[serde(default)] + mode: FillMode, + }, + + // --- 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, + /// The heat the inserted lap belongs to, so the scorer routes it by tag even when a + /// different heat is live (marshaling a finished heat mid-event). `None` only from a + /// legacy client — that insertion attributes positionally, the old behavior. + #[serde(default)] + #[ts(optional)] + heat: Option, + }, + /// 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, + }, + /// **Split** one over-long lap (the lap *ending* at `target`) into two by inserting a + /// synthetic mid-lap pass at `at` (`Event::LapSplit`) — the FPVTrackside split action for + /// a missed mid-lap detection. A distinct command from `InsertLap` so the audit names it. + SplitLap { + /// The log offset of the pass that ends the over-long lap to split. + target: LogRef, + /// When the inserted mid-lap crossing happened, 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`). Covers the full + /// penalty set: a (reversible) DQ with an optional reason, added time, and the standings-only + /// points adjustments (`PointsDeducted` / `PointsAdded`). `DeductPoints` is sugar over this for + /// the points case; either path appends the same `PenaltyApplied`. + ApplyPenalty { + /// The heat the penalty applies in. + heat: HeatId, + /// The competitor penalized. + competitor: CompetitorRef, + /// The penalty applied. + penalty: Penalty, + }, + /// **Deduct standings points** from a competitor in a heat (marshaling Slice 6) — sugar over + /// [`ApplyPenalty`](Command::ApplyPenalty) with a + /// [`Penalty::PointsDeducted`](gridfpv_events::Penalty::PointsDeducted), so the console can offer + /// a dedicated points control. Points affect the **season / event standings**, not the per-heat + /// lap result. + DeductPoints { + /// The heat the deduction is recorded against. + heat: HeatId, + /// The competitor losing points. + competitor: CompetitorRef, + /// How many standings points to deduct. + points: u32, + }, + /// **Throw out a single valid lap** from a competitor's scored count (`Event::LapThrownOut`), + /// referenced by the lap's **end-pass** log offset. The lap stays real in the lap list/audit; + /// it is only excluded from scoring. Distinct from `VoidDetection` (which removes the pass). + ThrowOutLap { + /// The log offset of the pass that *ends* the lap to throw out. + target: LogRef, + }, + /// **File a protest** against a heat result (`Event::ProtestFiled`) — the append-only filing + /// half of the protest pair (resolved later by `ResolveProtest`). No actor (no-login; filed at + /// the RD console on a pilot's behalf). + FileProtest { + /// The heat the protest concerns. + heat: HeatId, + /// The competitor the protest is about. + competitor: CompetitorRef, + /// A free-text note describing the protest. + note: String, + }, + /// **Resolve a filed protest** (`Event::ProtestResolved`), referenced by the + /// `ProtestFiled`'s log offset, recording the `outcome`. + ResolveProtest { + /// The log offset of the `ProtestFiled` this resolves. + target: LogRef, + /// How the protest was resolved. + outcome: ProtestOutcome, + }, + /// **Reverse a prior ruling** (`Event::RulingReversed`), referenced by its log offset. + /// Generalized (Slice 6) to undo **any** ruling — a [`PenaltyApplied`](gridfpv_events::Event::PenaltyApplied) + /// (DQ / time / points), a [`LapThrownOut`](gridfpv_events::Event::LapThrownOut), a + /// [`ProtestResolved`](gridfpv_events::Event::ProtestResolved), or a + /// [`HeatVoided`](gridfpv_events::Event::HeatVoided). A distinct command from `VoidDetection` so + /// the audit reads "DQ reversed" / "throw-out reversed". + ReverseRuling { + /// The log offset of the ruling to reverse. + target: LogRef, + }, +} + +/// How much of a round a [`Command::FillRound`] fills (#216). Externally tagged like the rest +/// of the control vocabulary, so it maps to a TS string-union (`"Next" | "All"`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum FillMode { + /// Schedule the **single** next heat the round's generator emits — the original + /// (interactive) fill, and the building block `All` iterates. The default, so an older + /// `FillRound { round }` payload (no `mode`) still deserializes to it. + #[default] + Next, + /// Fill the **whole** round: loop the generator (append a heat → re-fold the round's + /// state → draw the next) until it reports complete — a deterministic round drawn in one + /// command. Idempotent on a round already at its terminal state (appends nothing). + All, +} + +/// 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::Start { + heat: HeatId("q-1".into()), + }, + Command::SkipCountdown { + heat: HeatId("q-1".into()), + }, + Command::ForceEnd { + heat: HeatId("q-1".into()), + }, + Command::Finalize { + heat: HeatId("q-1".into()), + }, + Command::Advance { + heat: HeatId("q-1".into()), + }, + Command::Revert { + 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()), + }, + Command::SetCurrentHeat { + 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()), + ], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + Command::ScheduleHeat { + heat: HeatId("main-a".into()), + lineup: vec![ + CompetitorRef("node-0".into()), + CompetitorRef("node-1".into()), + ], + class: Some(ClassId("open".into())), + round: Some(RoundId("r1".into())), + frequencies: vec![ + (CompetitorRef("node-0".into()), 5658), + (CompetitorRef("node-1".into()), 5695), + ], + label: Some("Featured Heat".into()), + }, + Command::FillRound { + round: RoundId("qualifying-r1-abc".into()), + mode: FillMode::Next, + }, + Command::FillRound { + round: RoundId("qualifying-r1-abc".into()), + mode: FillMode::All, + }, + 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_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), + heat: Some(HeatId("main-a".into())), + }, + Command::AdjustLap { + target: LogRef(43), + at: SourceTime::from_micros(5_100_000), + }, + Command::SplitLap { + target: LogRef(44), + at: SourceTime::from_micros(5_050_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 }, + }, + Command::ApplyPenalty { + heat: HeatId("main-a".into()), + competitor: CompetitorRef("A".into()), + penalty: Penalty::Disqualify { + reason: Some("unsafe".into()), + }, + }, + Command::DeductPoints { + heat: HeatId("main-a".into()), + competitor: CompetitorRef("A".into()), + points: 5, + }, + Command::ThrowOutLap { target: LogRef(46) }, + Command::FileProtest { + heat: HeatId("main-a".into()), + competitor: CompetitorRef("B".into()), + note: "contact on lap 2".into(), + }, + Command::ResolveProtest { + target: LogRef(47), + outcome: ProtestOutcome::Denied, + }, + Command::ReverseRuling { target: LogRef(45) }, + ]; + 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/control_handler.rs b/crates/server/src/control_handler.rs new file mode 100644 index 0000000..2d44c64 --- /dev/null +++ b/crates/server/src/control_handler.rs @@ -0,0 +1,4615 @@ +//! 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`/`Start`/`SkipCountdown`/`ForceEnd`/`Finalize`/`Advance`/`Revert`/`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`] | the id is genuinely **new**, the lineup seats no competitor twice, and a `round`/`class` tag resolves against the event meta (round exists; class selected + round-eligible; pilot refs are eligible members) — #335 | [`Event::HeatScheduled`] | +//! | [`Command::SetCurrentHeat`] | the heat exists in the log | [`Event::CurrentHeatSelected`] | +//! | [`Command::Register`] | none (the binding is always recordable; last-registration-wins folds downstream) | [`Event::CompetitorRegistered`] | +//! | [`Command::VoidDetection`] | the `target` offset exists and is a lap-gate pass (raw [`Pass`](gridfpv_events::Pass), or a synthetic `LapInserted`/`LapSplit`); the target's owning heat is not **Final** | [`Event::DetectionVoided`] | +//! | [`Command::AdjustLap`] | the `target` offset exists and is a lap-gate pass (raw or synthetic, as above); the target's owning heat is not **Final** | [`Event::LapAdjusted`] | +//! | [`Command::InsertLap`] | a tagged insert names a scheduled heat that is not **Final**; an untagged (legacy) one requires the positionally-active heat at the log tail to not be **Final** | [`Event::LapInserted`] | +//! | [`Command::SplitLap`] | the `target` offset exists and is a [`Pass`](gridfpv_events::Pass) (the lap's ending pass); the target's owning heat is not **Final** | [`Event::LapSplit`] | +//! | [`Command::VoidHeat`] | the heat exists in the log and is not **Final** | [`Event::HeatVoided`] | +//! | [`Command::ApplyPenalty`] | the heat exists in the log and is not **Final** | [`Event::PenaltyApplied`] | +//! | [`Command::DeductPoints`] | the heat exists in the log and is not **Final** | [`Event::PenaltyApplied`] (`PointsDeducted`) | +//! | [`Command::ThrowOutLap`] | the `target` offset exists and is a [`Pass`](gridfpv_events::Pass); the target's owning heat is not **Final** | [`Event::LapThrownOut`] | +//! | [`Command::FileProtest`] | the heat exists in the log (**allowed on a Final heat** — filing changes no result) | [`Event::ProtestFiled`] | +//! | [`Command::ResolveProtest`] | the `target` offset exists and is a [`Event::ProtestFiled`] (**allowed on a Final heat** — resolving changes no result) | [`Event::ProtestResolved`] | +//! | [`Command::ReverseRuling`] | the `target` offset exists and is a reversible ruling (penalty / throw-out / protest resolution / heat-void); the target's owning heat is not **Final** | [`Event::RulingReversed`] | +//! +//! ## An official (Final) result is locked — Revert to marshal +//! +//! Every **result-changing** marshaling command above is additionally gated on the marshaled +//! heat's folded state ([`heat::heat_state`], the same fold the FSM checks use) not being +//! [`Final`](gridfpv_engine::heat::HeatState::Final): an official result must not silently +//! re-score under a ruling — the RD `Revert`s it first (the sanctioned re-open), marshals, and +//! re-finalizes. Heat-addressed commands check their own heat ([`require_not_final`]); +//! target-addressed ones resolve the target's **owning heat** first ([`heat_of_offset`] — +//! by tag, by ruling-chain recursion, or positionally) and check that. The heat-loop +//! transitions themselves (`Revert`/`Discard`/`Restart`, …) are untouched. **Protests are +//! exempt**: filing and resolving a protest changes no result, so both stay legal on a Final +//! heat (an upheld protest is then acted on via Revert, where the open-protest Finalize gate +//! composes correctly). +//! +//! ## 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 binds a competitor to a pilot (#60) +//! +//! [`Command::Register`] appends [`Event::CompetitorRegistered`] — the logged binding +//! "this source competitor *is* this event-scoped pilot" (Architecture §9), the action the +//! adapter never performs itself. There is nothing to validate against current state: a +//! binding is always recordable, and a re-bind of the same `(adapter, competitor)` is a +//! fresh append that supersedes the earlier one (last-registration-wins is folded +//! downstream by the registrations projection, not enforced here). The live and lap +//! projections fold these bindings to surface the pilot identity over a bare +//! [`CompetitorRef`](gridfpv_events::CompetitorRef). +//! +//! # 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::rejection::JsonRejection; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{FromRequest, FromRequestParts, Path, Request, 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, HeatTransition, LogRef}; + +use crate::app::{AppState, resolve_event}; +use crate::control::{Command, CommandAck, FillMode}; +use crate::error::{ErrorCode, ProtocolError}; +use crate::events::EventRegistry; +use crate::scope::EventId; + +/// A JSON body extractor that fails with the shared [`ProtocolError`] shape instead of axum's +/// bare-text rejection — the papercut fix for `POST /events/{id}/control`. +/// +/// Wrapping [`axum::Json`] inherits its parsing, but its default [`JsonRejection`] renders as a +/// plain-text 4xx (e.g. `Expected request with Content-Type: application/json`) that a client +/// cannot parse as the uniform [`ProtocolError`] every other API surface returns. This newtype +/// maps **every** rejection cause — a missing/wrong `Content-Type`, malformed JSON, a schema +/// mismatch, an oversized/unreadable body — to a typed [`ProtocolError`] of +/// [`ErrorCode::BadRequest`] (HTTP 400) carrying the cause as its message, so the control endpoint +/// answers the same JSON error shape as the rest of the API. Generic over the body type so any +/// JSON handler can opt in. +pub struct JsonBody(pub T); + +impl FromRequest for JsonBody +where + S: Send + Sync, + Json: FromRequest, +{ + type Rejection = ProtocolError; + + async fn from_request(req: Request, state: &S) -> Result { + match Json::::from_request(req, state).await { + Ok(Json(value)) => Ok(JsonBody(value)), + // Every `JsonRejection` cause (missing/wrong Content-Type, malformed/unreadable body, + // schema mismatch) is a malformed request → a typed `BadRequest` JSON error, not a + // bare-text 4xx. The rejection's own message is the human-readable detail. + Err(rejection) => Err(ProtocolError::new( + ErrorCode::BadRequest, + rejection.body_text(), + )), + } + } +} + +/// The **auth chokepoint** for the privileged control path (protocol.html §5, §9.4) — #44. +/// +/// An axum extractor every control route demands *before* its handler runs: it reads the +/// caller's `Authorization: Bearer ` and requires a token resolving to a +/// **control-authorized** ([`Role::Rd`](crate::auth::Role::Rd)) session in the shared +/// [`TokenStore`](crate::auth::TokenStore). A valid RD token yields the marker; anything +/// else — no header, a read-only/join token, an unknown or revoked token — is rejected +/// with [`ErrorCode::Unauthorized`] (HTTP 401 / a failed ack). The whole policy lives in +/// [`TokenStore::authenticate_control`](crate::auth::TokenStore::authenticate_control); +/// this extractor is just where it is applied. +/// +/// Because every control route lists `ControlAuth` as its first extractor, this gates +/// `POST /control` and the `GET /control` upgrade at once **without touching a single +/// handler body** — the seam #45 left. Reads, by contrast, stay open on the LAN (§5; see +/// [`crate::auth`]). +#[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 { + type Rejection = ProtocolError; + + async fn from_request_parts( + parts: &mut Parts, + registry: &EventRegistry, + ) -> Result { + // Read the bearer token (if any) and require a control-authorized RD session; every + // non-RD case maps to `ErrorCode::Unauthorized` inside the store. The token store is + // Director-wide (shared across events via the registry), so one RD token authorizes + // control on every event — control is gated by *role*, the event is resolved per + // handler from the path. + let token = crate::auth::bearer_token(parts); + registry.tokens().authenticate_control(token.as_deref())?; + 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("/events/{event_id}/control", control_method_router()) +} + +/// `GET /events/{event_id}/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(registry): State, + Path(event_id): Path, + JsonBody(command): JsonBody, +) -> Result, ProtocolError> { + // Resolve the event first (an unknown id → typed 404) so the command applies to THAT + // event's log only — commands never cross event boundaries. + let state = resolve_event(®istry, &event_id)?; + Ok(Json(apply_command_in_event( + ®istry, &event_id, &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(registry): State, + Path(event_id): Path, +) -> Result { + // Resolve the event before the upgrade so every command on this socket drives THAT + // event's log (an unknown id → typed 404 before upgrading). + let state = resolve_event(®istry, &event_id)?; + Ok(ws.on_upgrade(move |socket| run_control(socket, registry, event_id, 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, + registry: EventRegistry, + event_id: EventId, + 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_in_event(®istry, &event_id, &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_in_event(®istry, &event_id, &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 + } + } +} + +/// Apply a [`Command`] in the context of a known event (race redesign Slice 3a) — the +/// event-aware control write path both endpoints use. +/// +/// All commands except [`Command::FillRound`] are pure log writes that need only the +/// event's [`AppState`], so they delegate straight to [`apply_command`]. `FillRound` is the +/// one command that also needs the event's **meta** (its rounds + class membership) to build +/// the round's generator, so it is handled here where the [`EventRegistry`] and +/// [`EventId`] are in scope — see [`apply_fill_round`]. +pub fn apply_command_in_event( + registry: &EventRegistry, + event_id: &EventId, + state: &AppState, + command: Command, +) -> CommandAck { + match command { + Command::FillRound { round, mode } => { + apply_fill_round(registry, event_id, state, round, mode) + } + // `Advance` is more than the `Final → Advanced` transition: advancing a finalized heat + // **loads the next heat to run** into Live control. That may need a generator draw (a + // round mid-fill) and always needs to select the next heat, so it runs through the + // event-aware path where the registry/meta are in scope — see [`apply_advance`]. + Command::Advance { heat } => apply_advance(registry, event_id, state, heat), + // `ScheduleHeat` also needs the event meta + timer registry (the channel cap + assignment), + // so it is handled here rather than in the log-only `apply_command`. + Command::ScheduleHeat { + heat, + lineup, + class, + round, + frequencies, + label, + } => apply_schedule_heat( + registry, + event_id, + state, + heat, + lineup, + class, + round, + frequencies, + label, + ), + other => apply_command(state, other), + } +} + +/// Handle [`Command::ScheduleHeat`] (race redesign Slice 4a) — create a heat with its lineup, with +/// the **channel assignment + heat-size cap** the round-driven path also applies. +/// +/// Validated before anything is appended (#335, the #330 hardening pattern — a raw API call must +/// not lay down a heat the UI could never build); every failure is a typed `400`: +/// +/// - the heat id is genuinely **new** ([`require_new_heat_id`] — a duplicate would re-seed an +/// existing, possibly Final, heat back to `Scheduled`); +/// - the lineup seats no competitor twice ([`require_distinct_lineup`]); +/// - a `round` tag names one of the event's rounds; a `class` tag names a class the event +/// selects — and, when both are tagged, one **eligible** for that round; +/// - on a tagged heat, every lineup ref that names a **directory pilot** is a member of the +/// tagged round's eligible classes' membership (the same field FillRound / the console's +/// eligible-members picker resolves) — see [`validate_tagged_lineup`]. Non-pilot refs +/// (`node-{i}` timer seats, sim free-text names) pass through, so practice-style heats keep +/// scheduling. +/// +/// The cap (lineup ≤ the event's effective primary timer's node count) is enforced here and an +/// oversized lineup is a typed `400` (nothing appended). Channels are assigned from the timer's +/// available set unless the caller supplied an explicit `frequencies` set (the caller — a test, a +/// manual override — wins; the engine assignment is the default when none is given). A pure-sim +/// event (no resolvable timer) assigns none. +#[allow(clippy::too_many_arguments)] +fn apply_schedule_heat( + registry: &EventRegistry, + event_id: &EventId, + state: &AppState, + heat: HeatId, + lineup: Vec, + class: Option, + round: Option, + frequencies: Vec<(gridfpv_events::CompetitorRef, u16)>, + label: Option, +) -> CommandAck { + use crate::round_engine; + + // Validate→append under the command serialization lock (see `apply_command`): two + // concurrent ScheduleHeats with one id must not both pass the fresh-id check. + let _guard = state.command_guard(); + let Some(meta) = registry.meta_of(event_id) else { + return CommandAck::failed(ProtocolError::new( + ErrorCode::UnknownScope, + format!("no event with id {:?}", event_id.0), + )); + }; + + // #335 validation, all-or-nothing before the append: the log-only guards (fresh id, distinct + // lineup) plus the meta-scoped tag/membership checks. + if let Err(err) = require_new_heat_id(state, &heat) + .and_then(|()| require_distinct_lineup(&lineup)) + .and_then(|()| validate_tagged_lineup(registry, &meta, &lineup, &class, &round)) + { + return CommandAck::failed(err); + } + + // Caller-supplied frequencies win (manual override / test); otherwise assign from the event's + // timer. Either way the heat-size cap is enforced against the event's timer. + let frequencies = if frequencies.is_empty() { + match round_engine::assign_for_event(&meta, ®istry.timers(), &lineup) { + Ok(freqs) => freqs, + Err(err) => { + return CommandAck::failed(ProtocolError::new( + ErrorCode::BadRequest, + err.to_string(), + )); + } + } + } else { + // A caller-supplied assignment still must fit the timer's node count (the cap). + if let Some(timer) = round_engine::assignment_timer(&meta, ®istry.timers()) { + if lineup.len() > timer.node_count as usize { + return CommandAck::failed(ProtocolError::new( + ErrorCode::BadRequest, + round_engine::AssignError::TooManyForNodes { + lineup: lineup.len(), + nodes: timer.node_count as usize, + } + .to_string(), + )); + } + } + frequencies + }; + + let event = Event::HeatScheduled { + heat, + lineup, + class, + round, + frequencies, + label, + }; + match state.append(event, None) { + Ok(_offset) => CommandAck::ok(), + Err(err) => CommandAck::failed(err), + } +} + +/// Require that `heat` names a genuinely **new** heat — one the log never scheduled (#335). +/// +/// The fold ([`heat::heat_state`]) deliberately re-seeds a repeated `HeatScheduled` back to +/// `Scheduled` (robustness on replay), so *accepting* a duplicate id would silently reset an +/// existing — possibly finished/**Final** — heat and orphan its result (#341). A re-run of a raced +/// heat is a `Discard`/`Restart` transition on the existing heat, never a re-schedule; a new heat +/// takes a fresh id (the console mints collision-checked ids for exactly this reason). +fn require_new_heat_id(state: &AppState, heat: &HeatId) -> Result<(), ProtocolError> { + let (events, _cursor) = state.read()?; + if heat::heat_state(&events, heat).is_some() { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "a heat with id {:?} already exists; use Discard/Restart to re-run it, or pick a \ + fresh id", + heat.0 + ), + )); + } + Ok(()) +} + +/// Reject a lineup that seats the **same competitor twice** (#335): a lineup ref is the handle +/// passes/channels key on, so a duplicate would merge two seats into one pilot's lap stream. +fn require_distinct_lineup(lineup: &[gridfpv_events::CompetitorRef]) -> Result<(), ProtocolError> { + // An empty lineup is a heat nobody can fly — stageable but unraceable (raw-API guard). + if lineup.is_empty() { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + "a heat needs at least one competitor in its lineup", + )); + } + let mut seen = std::collections::BTreeSet::new(); + for competitor in lineup { + if !seen.insert(competitor.0.as_str()) { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "competitor {:?} appears more than once in the lineup", + competitor.0 + ), + )); + } + } + Ok(()) +} + +/// Validate a `ScheduleHeat`'s **round/class tag + lineup** against the event meta (#335). +/// +/// - A `round` tag must name one of the event's rounds; a `class` tag must be one the event +/// selects and — when a round is also tagged — **eligible** for that round (in the round's +/// `classes`). An untagged (free-text / practice) heat skips all of this. +/// - On a tagged heat, every lineup ref that names a **directory pilot** must be an *eligible +/// member*: the union of the tagged round's eligible classes' membership (mirroring how +/// FillRound's `FromRoster` field and the console's eligible-members picker resolve), or the +/// tagged class's own membership when only a class is tagged. Refs that are **not** directory +/// pilots pass through — `node-{i}` timer seats (the open-practice channel lineup) and sim +/// free-text names have no membership to check — so practice-style heats keep scheduling. +fn validate_tagged_lineup( + registry: &EventRegistry, + meta: &crate::events::EventMeta, + lineup: &[gridfpv_events::CompetitorRef], + class: &Option, + round: &Option, +) -> Result<(), ProtocolError> { + // (1) The round tag resolves to this event's round definition. + let round_def = match round { + Some(id) => match meta.rounds.iter().find(|r| &r.id == id) { + Some(def) => Some(def), + None => { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!("no round with id {:?} in this event", id.0), + )); + } + }, + None => None, + }; + + // (2) The class tag is selected by the event, and eligible for the tagged round. + if let Some(class_id) = class { + if !meta.classes.contains(class_id) { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!("class {:?} is not selected by this event", class_id.0), + )); + } + if let Some(def) = round_def { + if !def.classes.contains(class_id) { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "class {:?} is not eligible for round {:?}", + class_id.0, def.id.0 + ), + )); + } + } + } + + // (3) Membership: only for a tagged heat, and only for pilot-shaped refs. + if round_def.is_none() && class.is_none() { + return Ok(()); + } + // The eligible member set — the tagged round's eligible classes (the round wins when both are + // tagged: its `classes` already contain the class per check 2), else the tagged class alone. + let eligible_classes: Vec<&gridfpv_events::ClassId> = match round_def { + Some(def) => def.classes.iter().collect(), + None => class.iter().collect(), + }; + let mut members = std::collections::BTreeSet::new(); + for cls in eligible_classes { + if let Some(membership) = meta.classes_membership.iter().find(|m| &m.class == cls) { + for slot in &membership.pilots { + members.insert(slot.pilot.0.as_str()); + } + } + } + let pilots = registry.pilots(); + for competitor in lineup { + // A ref is "pilot-shaped" when it names a directory pilot — the console's build path + // seats pilot ids verbatim as refs. Anything else (node seats, free-text) passes. + if pilots.exists(&gridfpv_events::PilotId(competitor.0.clone())) + && !members.contains(competitor.0.as_str()) + { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "pilot {:?} is not a member of the tagged round/class's eligible classes", + competitor.0 + ), + )); + } + } + Ok(()) +} + +/// The defensive cap on `FillMode::All`'s loop (#216): a real deterministic format always +/// converges to `Complete` in far fewer than this, so hitting it means the generator never +/// reported done — a logic bug, not a request for a 1000th heat. We stop and log rather than +/// spin unbounded. (Mirrors [`round_engine::MAX_HEATS_PER_ROUND`].) +const MAX_FILL_ALL_HEATS: usize = 1_000; + +/// One iteration of filling a round: build the generator from the current log, and if it emits +/// the next heat, append the tagged [`Event::HeatScheduled`]. Returns whether a heat was +/// appended (so the `All` loop knows to draw again) or the round reached a terminal/no-op state, +/// or a failed ack to surface verbatim. +enum FillStep { + /// A heat was appended — re-fold and draw the next (the `All` loop continues here). + Appended, + /// `Complete`/`AlreadyScheduled` — nothing to append now; the round's done for this command. + Terminal, + /// A fill or append error; carries the ack to return as-is. + Failed(CommandAck), +} + +/// Run the generator once against the current log and append at most one heat. Re-reads the log +/// each call so a just-appended heat is folded in on the next — this is what lets `FillMode::All` +/// iterate by simply calling this until it reports [`FillStep::Terminal`]. +fn fill_round_once( + registry: &EventRegistry, + meta: &crate::events::EventMeta, + state: &AppState, + round: &gridfpv_events::RoundId, +) -> FillStep { + use crate::round_engine::{self, FillError, FillOutcome}; + + let events = match state.read() { + Ok((events, _cursor)) => events, + Err(err) => return FillStep::Failed(CommandAck::failed(err)), + }; + + match round_engine::fill_round(meta, ®istry.timers(), round, &events) { + Ok(FillOutcome::Scheduled { + heat, + lineup, + frequencies: static_freqs, + field_draw, + }) => { + let class = round_engine::round_class(meta, round); + // Channel assignment differs by the round's channel mode (race redesign Slice 7a): + // + // - **Static** (`static_freqs` is `Some`): the channel-balanced builder already chose + // each pilot's fixed membership channel; use them directly. The heat-size cap was + // honoured by the builder (heats are ≤ node_count), but re-check defensively against + // the timer node count so an oversized heat never slips through. + // - **Per-heat** (`static_freqs` is `None`): first-fit from the timer's pool (Slice 4a), + // which also enforces the node-count cap. + let frequencies = match static_freqs { + Some(freqs) => { + if let Some(timer) = round_engine::assignment_timer(meta, ®istry.timers()) { + if lineup.len() > timer.node_count as usize { + return FillStep::Failed(CommandAck::failed(ProtocolError::new( + ErrorCode::BadRequest, + round_engine::AssignError::TooManyForNodes { + lineup: lineup.len(), + nodes: timer.node_count as usize, + } + .to_string(), + ))); + } + } + freqs + } + None => match round_engine::assign_for_event(meta, ®istry.timers(), &lineup) { + Ok(freqs) => freqs, + Err(err) => { + return FillStep::Failed(CommandAck::failed(ProtocolError::new( + ErrorCode::BadRequest, + err.to_string(), + ))); + } + }, + }; + // FREEZE-AT-FILL (#334): a carry-seeded round's first fill records its resolved + // field BEFORE the heat, so every later read (fills, ranking, standings, dependent + // seeding) replays this draw instead of re-resolving a source whose adjudications + // may have since moved. + if let Some(field) = field_draw { + if let Err(err) = state.append( + Event::RoundFieldDrawn { + round: round.clone(), + field, + }, + None, + ) { + return FillStep::Failed(CommandAck::failed(err)); + } + } + let event = Event::HeatScheduled { + heat, + lineup, + class, + round: Some(round.clone()), + frequencies, + // A generator-filled heat keeps the derived auto-name (no custom label). + label: None, + }; + match state.append(event, None) { + Ok(_offset) => FillStep::Appended, + Err(err) => FillStep::Failed(CommandAck::failed(err)), + } + } + // Complete / AlreadyScheduled: nothing to append, a successful terminal state. + Ok(FillOutcome::Complete) | Ok(FillOutcome::AlreadyScheduled) => FillStep::Terminal, + Err(err @ FillError::UnknownRound(_)) => FillStep::Failed(CommandAck::failed( + ProtocolError::new(ErrorCode::UnknownScope, err.to_string()), + )), + Err(err) => FillStep::Failed(CommandAck::failed(ProtocolError::new( + ErrorCode::BadRequest, + err.to_string(), + ))), + } +} + +/// Handle [`Command::FillRound`] (race redesign Slice 3a; fill-all added #216): build the round's +/// generator from the event meta + the log and append heat(s) per `mode`, then ack. +/// +/// - [`FillMode::Next`] runs the generator **once**: a `Scheduled` outcome appends the heat tagged +/// with `round` (and `class` when single-class), lineup from the generator's plan; a +/// `Complete`/`AlreadyScheduled` appends nothing. The interactive single-step (Open Practice, and +/// the building block). +/// - [`FillMode::All`] loops [`fill_round_once`] — append, re-fold, draw again — until the round +/// reports terminal (`Complete`/`AlreadyScheduled`), filling a whole deterministic round in one +/// command. The loop re-reads the log each pass so the generator sees the just-appended heat, is +/// idempotent on an already-complete round (appends nothing), and is capped at +/// [`MAX_FILL_ALL_HEATS`] defensively (logged if hit). +/// +/// Either mode acks ok once it reaches the terminal state, or acks failed (verbatim) with a +/// [`ProtocolError`] on a fill/append error — `UnknownScope` for a missing round, `BadRequest` +/// otherwise. Any heat appended before an error mid-batch stays in the log (each append is its own +/// committed event); the ack reports the failure. +pub fn apply_fill_round( + registry: &EventRegistry, + event_id: &EventId, + state: &AppState, + round: gridfpv_events::RoundId, + mode: FillMode, +) -> CommandAck { + // The whole read-draw-append loop runs under the command serialization lock (see + // `apply_command`): a concurrent fill/schedule must not interleave with the duplicate-id + // and round-state reads this fill bases its draws on. + let _guard = state.command_guard(); + let Some(meta) = registry.meta_of(event_id) else { + return CommandAck::failed(ProtocolError::new( + ErrorCode::UnknownScope, + format!("no event with id {:?}", event_id.0), + )); + }; + + match mode { + // The original single-step fill — one generator draw, at most one heat appended. + FillMode::Next => match fill_round_once(registry, &meta, state, &round) { + FillStep::Appended | FillStep::Terminal => CommandAck::ok(), + FillStep::Failed(ack) => ack, + }, + // Iterate the single step until the round is terminal, capped defensively. + FillMode::All => { + // An open-ended `Static` round (rounds=0) never reports Complete — its generator yields + // the next heat on demand forever — so a batch `All` fill would loop to the cap and + // schedule MAX_FILL_ALL_HEATS heats while acking ok (release-hardening P1-8). Reject it + // up front and steer the RD to single-step fills. + if let Some(def) = meta.rounds.iter().find(|r| r.id == round) { + if crate::round_engine::is_open_ended_static(def) { + return CommandAck::failed(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "round {:?} is open-ended (no fixed heat count); use single-step fill \ + instead of fill-all", + round.0 + ), + )); + } + } + for _ in 0..MAX_FILL_ALL_HEATS { + match fill_round_once(registry, &meta, state, &round) { + FillStep::Appended => continue, + FillStep::Terminal => return CommandAck::ok(), + FillStep::Failed(ack) => return ack, + } + } + eprintln!( + "FillRound(All) for round {:?} hit the {MAX_FILL_ALL_HEATS}-heat cap without the \ + generator reporting complete — stopping. A real format converges in far fewer; \ + this indicates a generator bug, not a {MAX_FILL_ALL_HEATS}-heat round.", + round.0, + ); + // A capped fill is a FAILURE the RD must see, not an ok with a thousand junk heats + // quietly in the append-only log — the heats it did draw are visible either way. + CommandAck::failed(ProtocolError::new( + ErrorCode::Internal, + format!( + "the round's generator never reported complete after {MAX_FILL_ALL_HEATS} \ + heats — stopping the fill; check the round's format configuration" + ), + )) + } + } +} + +/// Handle [`Command::Advance`] — advancing a finalized heat **loads the next heat to run**. +/// +/// Before this, `Advance` only recorded the `Final → Advanced` transition (which leaves the heat +/// `Final` — a terminal off-ramp) and nothing else moved: Live control stayed on the just-finished +/// heat, so the button looked like a no-op. Now it does the two-step the RD expects: +/// +/// 1. Append the `Advanced` transition (reusing the engine's legality via [`heat_transition`]); a +/// heat that is not `Final` (or never scheduled) is rejected verbatim and nothing else happens. +/// 2. Find the **next heat to run** and select it (so the `current_heat` fold follows it): +/// - the **on-deck** heat — the next still-`Scheduled` heat — if one already exists; else +/// - **generate** the next heat for the advanced heat's round (one generator draw, the same +/// single-step path [`Command::FillRound`] `Next` uses) and select that; else +/// - **nothing to advance to** (the round is complete / the heat was untagged) — leave the heat +/// `Advanced` (a clear terminal), ack ok. No crash, no spurious selection. +/// +/// Each step is its own append; a generated heat plus the selection are two events, which is why +/// this lives on the event-aware path (it needs the registry/meta to draw, like `FillRound`). +fn apply_advance( + registry: &EventRegistry, + event_id: &EventId, + state: &AppState, + heat: HeatId, +) -> CommandAck { + // The multi-step advance (transition + generator draw + selection) runs under the command + // serialization lock, like every other validated write. + let _guard = state.command_guard(); + // 1. Record the `Final → Advanced` transition (engine legality + event shape reused). A + // non-`Final` or unknown heat is rejected verbatim here, appending nothing. + let advanced = match heat_transition(state, heat.clone(), HeatCommand::Advance) { + Ok(event) => event, + Err(err) => return CommandAck::failed(err), + }; + if let Err(err) = state.append(advanced, None) { + return CommandAck::failed(err); + } + + // 2. Pick the next heat to run. The advanced heat is now `Final` (not `Scheduled`), so + // `on_deck` against it is exactly "the next still-`Scheduled` heat" — the heat to load. + let next = { + let (events, _cursor) = match state.read() { + Ok(read) => read, + Err(err) => return CommandAck::failed(err), + }; + crate::live_state::on_deck(&events, &heat) + }; + + if let Some(next) = next { + // A next heat is already scheduled — follow Live control to it. + return select_next_heat(state, next); + } + + // Nothing is on deck: ask the advanced heat's round generator for the next heat (a round + // mid-fill), then select whatever it scheduled. An untagged heat has no round to draw from. + let round = { + let (events, _cursor) = match state.read() { + Ok(read) => read, + Err(err) => return CommandAck::failed(err), + }; + crate::live_state::round_of_heat(&events, &heat) + }; + if let Some(round) = round { + if let Some(meta) = registry.meta_of(event_id) { + // One generator draw (the `FillMode::Next` step). It either appends the next heat or + // reports the round terminal (nothing to schedule). + match fill_round_once(registry, &meta, state, &round) { + FillStep::Appended => { + // A heat was just scheduled in this round; on_deck now finds it. Select it. + let next = { + let (events, _cursor) = match state.read() { + Ok(read) => read, + Err(err) => return CommandAck::failed(err), + }; + crate::live_state::on_deck(&events, &heat) + }; + if let Some(next) = next { + return select_next_heat(state, next); + } + } + // Round complete / already-scheduled-elsewhere: nothing more to load. + FillStep::Terminal => {} + // A generator/append error: surface it verbatim (the Advanced transition stands). + FillStep::Failed(ack) => return ack, + } + } + } + + // The round is genuinely complete (or the heat was untagged and nothing is on deck): the heat + // stays `Advanced`, a clean terminal. Ack ok — Advance succeeded, there was just no next heat. + CommandAck::ok() +} + +/// Append a [`Event::CurrentHeatSelected`] to move Live control onto `next`, the same selection the +/// RD's manual "select heat" records — so the `current_heat` fold follows it. The heat is one we +/// just derived from the log (on-deck / freshly generated), so it is known-scheduled; no further +/// validation needed. +fn select_next_heat(state: &AppState, next: HeatId) -> CommandAck { + match state.append(Event::CurrentHeatSelected { heat: next }, None) { + Ok(_offset) => CommandAck::ok(), + Err(err) => CommandAck::failed(err), + } +} + +/// 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 { + // The whole validate→append runs under the command serialization lock: without it, a + // concurrent appender (the auto-official driver, another console) could change the very + // state the validation just read — a ruling landing on a heat that went Final in the + // window, Finalize slipping past a fresh protest, duplicate heat ids both passing. + let _guard = state.command_guard(); + 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::Start { heat } => heat_transition(state, heat, HeatCommand::Start), + Command::SkipCountdown { heat } => heat_transition(state, heat, HeatCommand::SkipCountdown), + Command::ForceEnd { heat } => heat_transition(state, heat, HeatCommand::ForceEnd), + Command::Finalize { heat } => { + // Gate finalize on **open protests** (release-hardening P1-4): a heat with a filed, + // unresolved protest must not be finalized — the result is still contested. The RD + // resolves (or reverses a resolution of) the protest first. The auto-official protest + // window appends its `Finalized` directly (not through this command path) but checks + // this same `open_protest_count` predicate before doing so (issue #338). + let (events, _cursor) = state.read()?; + let open = open_protest_count(&events, &heat); + if open > 0 { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "cannot finalize heat {:?}: resolve {open} open protest(s) first", + heat.0 + ), + )); + } + heat_transition(state, heat, HeatCommand::Finalize) + } + // `Advance` on the real control path is intercepted by `apply_command_in_event` + // (it loads the next heat too — see `apply_advance`). On the bare-`apply_command` + // path it records just the `Final → Advanced` transition (no next-heat selection), + // which is the legality-checked event with no event scope to draw a next heat from. + Command::Advance { heat } => heat_transition(state, heat, HeatCommand::Advance), + Command::Revert { heat } => heat_transition(state, heat, HeatCommand::Revert), + 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), + + // --- Live-control selection: validate the heat exists, reject while the current heat + // is mid-commit, then record the choice. Not a heat-loop transition — it moves Live + // control's focus, not the heat's state. --- + Command::SetCurrentHeat { heat } => { + require_scheduled_heat(state, &heat)?; + reject_if_current_heat_committed(state)?; + Ok(Event::CurrentHeatSelected { heat }) + } + + // --- Scheduling: creates the heat, so the prior-state check is INVERTED — the id must + // be new (a duplicate would re-seed an existing heat, #335) and the lineup distinct. + // The class/round/frequency tags are carried straight through (default-absent for the + // free-text path); the meta-scoped tag validation lives on the event-aware path + // (`apply_schedule_heat`), which is the only one the real control endpoints drive. --- + Command::ScheduleHeat { + heat, + lineup, + class, + round, + frequencies, + label, + } => { + require_new_heat_id(state, &heat)?; + require_distinct_lineup(&lineup)?; + Ok(Event::HeatScheduled { + heat, + lineup, + class, + round, + frequencies, + label, + }) + } + + // --- FillRound is intercepted by `apply_command_in_event` (it needs the event + // meta, not just the log) and never reaches here on the real control path. The arm + // keeps the match exhaustive; on the (test-only) bare-`apply_command` path it is a + // clear BadRequest rather than a silent append. --- + Command::FillRound { .. } => Err(ProtocolError::new( + ErrorCode::BadRequest, + "FillRound must be applied through the event-aware control path", + )), + + // --- Registration: bind a source competitor to a pilot (no prior-state check). --- + Command::Register { + adapter, + competitor, + pilot, + } => Ok(Event::CompetitorRegistered { + adapter, + competitor, + pilot, + }), + + // --- Marshaling adjudications: validate targets where cheap, reject any result-changing + // ruling on an OFFICIAL (Final) heat (Revert is the sanctioned re-open), then append. --- + Command::VoidDetection { target } => { + // A void may target a pass — or a prior DetectionVoided (void-the-void, the + // sanctioned RESTORE of a mistakenly-removed pass; the fold walks the chain). + require_void_target(state, target)?; + require_target_heat_not_final(state, target)?; + require_target_in_current_run(state, target)?; + // Voiding a pass whose lap carries an EFFECTIVE throw-out would leave the + // throw-out dangling while the neighbouring laps merge and COUNT — the ruling + // must be unwound first, in order. + require_no_effective_throw_out(state, target)?; + Ok(Event::DetectionVoided { target }) + } + Command::AdjustLap { target, at } => { + require_pass_target(state, target)?; + require_target_heat_not_final(state, target)?; + require_target_in_current_run(state, target)?; + require_sane_source_time(at)?; + { + let (events, _cursor) = state.read()?; + if let (Some(h), Some(c)) = ( + heat_of_offset(&events, target), + competitor_of_pass_target(&events, target), + ) { + // The re-timed pass itself is exempt (re-asserting its own instant is fine). + require_no_instant_collision(state, &h, &c, at, Some(target))?; + } + } + Ok(Event::LapAdjusted { target, at }) + } + Command::SplitLap { target, at } => { + // The split's target is the pass that *ends* the over-long lap — a real Pass, + // validated exactly like `VoidDetection`/`AdjustLap`. + require_pass_target(state, target)?; + require_target_heat_not_final(state, target)?; + require_target_in_current_run(state, target)?; + require_sane_source_time(at)?; + { + let (events, _cursor) = state.read()?; + if let (Some(h), Some(c)) = ( + heat_of_offset(&events, target), + competitor_of_pass_target(&events, target), + ) { + require_no_instant_collision(state, &h, &c, at, None)?; + } + } + Ok(Event::LapSplit { target, at }) + } + Command::InsertLap { + adapter, + competitor, + at, + heat, + } => { + // A tagged insertion must name a real heat (the tag is what routes it into that + // heat's scoring window even when a different heat is live) that is not Final; an + // untagged one is a legacy client and attributes positionally, so the lock checks + // the heat the insertion WOULD land in — the positionally-active heat at the log + // tail. + require_sane_source_time(at)?; + match &heat { + Some(h) => { + require_scheduled_heat(state, h)?; + require_not_final(state, h)?; + require_no_instant_collision(state, h, &competitor, at, None)?; + } + None => { + let (events, _cursor) = state.read()?; + if let Some(active) = events + .len() + .checked_sub(1) + .and_then(|tail| positional_heat_at(&events, tail)) + { + require_not_final_in(&events, &active)?; + } + } + } + Ok(Event::LapInserted { + adapter, + competitor, + at, + heat, + }) + } + Command::VoidHeat { heat } => { + require_scheduled_heat(state, &heat)?; + require_not_final(state, &heat)?; + // Voiding needs a run to void — a pre-run void was window-inert (it applied to + // nothing) yet blocked a real void later via the duplicate guard below. + require_heat_has_run(state, &heat)?; + // One EFFECTIVE void per heat *this run*: a stacked second void made the first + // reversal a silent no-op (the heat stayed voided behind an ok-acked + // ReverseRuling); a void from an ABANDONED run is inert and must not block. + require_heat_not_voided(state, &heat)?; + Ok(Event::HeatVoided { heat }) + } + Command::ApplyPenalty { + heat, + competitor, + penalty, + } => { + require_scheduled_heat(state, &heat)?; + require_not_final(state, &heat)?; + // A time penalty must WORSEN the target's result: a zero/negative `micros` (a + // typo'd sign, a buggy client) would silently *improve* the penalized pilot's + // deciding time while the audit trail reads as a penalty. + if let gridfpv_events::Penalty::TimeAdded { micros } = &penalty { + if *micros <= 0 { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + "a time penalty must add a positive number of microseconds", + )); + } + } + // ONE effective DQ per competitor per heat (time/points penalties stack by + // design; a status can't): a double-clicked duplicate made reversing "the" DQ a + // silent no-op — the stacked copy kept the pilot disqualified. + if matches!(&penalty, gridfpv_events::Penalty::Disqualify { .. }) { + require_not_already_disqualified(state, &heat, &competitor)?; + } + Ok(Event::PenaltyApplied { + heat, + competitor, + penalty, + }) + } + // Sugar over `ApplyPenalty` with a points-deduction penalty (standings-only, Slice 6). + Command::DeductPoints { + heat, + competitor, + points, + } => { + require_scheduled_heat(state, &heat)?; + require_not_final(state, &heat)?; + Ok(Event::PenaltyApplied { + heat, + competitor, + penalty: gridfpv_events::Penalty::PointsDeducted { points }, + }) + } + // Throw out a valid lap: the target is the lap's end pass. Unlike `VoidDetection`, an + // *inserted* or *split* lap is also throw-out-able (its `end_ref` addresses the synthetic + // pass the projection emits from the `LapInserted`/`LapSplit` event), so validate against + // any lap-end-producing event, not only a raw `Pass`. + Command::ThrowOutLap { target } => { + require_lap_end_target(state, target)?; + require_target_heat_not_final(state, target)?; + require_target_in_current_run(state, target)?; + // ONE effective throw-out per lap: a stacked duplicate made ReverseRuling a + // silent no-op (the other copy kept excluding the lap) — the same effectively- + // once rule VoidHeat and ResolveProtest already follow. + require_no_effective_throw_out(state, target)?; + Ok(Event::LapThrownOut { target }) + } + // File a protest against a heat result — the append-only filing fact. Deliberately NOT + // gated on Final: a protest changes no result, and disputing an already-official one is + // exactly what protests are for (the RD Reverts only if the protest is upheld). + Command::FileProtest { + heat, + competitor, + note, + } => { + require_scheduled_heat(state, &heat)?; + // A protest contests a RUN's result, so the heat must have one: filed before the + // heat ever ran (or in the gap after a reset), the filing counted for the + // Finalize gate but sat OUTSIDE every run-windowed audit view — an invisible + // blocker the RD couldn't resolve. + require_heat_has_run(state, &heat)?; + Ok(Event::ProtestFiled { + heat, + competitor, + note, + }) + } + // Resolve a filed protest — the target must be a real `ProtestFiled`. Also NOT gated on + // Final: a protest filed against an official result must be resolvable (e.g. denied) + // without re-opening it; an upheld one is acted on via Revert, where the open-protest + // Finalize gate composes correctly. + Command::ResolveProtest { target, outcome } => { + require_protest_target(state, target)?; + // One EFFECTIVE resolution per filing: a second (double-click, a second console) + // used to be recorded too — possibly with a contradictory outcome — and then + // reversing "the" resolution silently failed to re-open the protest (the other + // resolution still closed it). Reversing the standing resolution is the sanctioned + // way to re-decide. + require_protest_unresolved(state, target)?; + Ok(Event::ProtestResolved { target, outcome }) + } + Command::ReverseRuling { target } => { + // Generalized reversal (Slice 6): the target must be a real *ruling* — a penalty, a + // throw-out, a protest resolution, or a heat-void. Validated so an out-of-range or + // non-ruling offset is a typed BadRequest (nothing appended). Reversal DOES change + // the result, so it is uniformly locked on a Final heat (even when its target is a + // protest resolution — revert-first keeps the official record honest). + require_ruling_target(state, target)?; + require_target_heat_not_final(state, target)?; + Ok(Event::RulingReversed { target }) + } + } +} + +/// 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 }) +} + +/// Reject a current-heat change while the **current heat is mid-commit** — its folded phase +/// is `Staged`, `Armed`, or `Running` (race-engine.html §2). After Stage the RD is committed +/// to that race; switching focus is only allowed once it is aborted back to `Scheduled` or +/// finishes to `Unofficial`/`Final` (and is always allowed when there is no current heat, or +/// the current heat is still `Scheduled`). +/// +/// Computed from the same live-state derivation the read path uses (the `current_heat` fold + +/// `heat::heat_state`/[`HeatState`](gridfpv_engine::heat::HeatState)), so the lock matches what +/// the live view shows and replays deterministically. A locked phase maps to a typed +/// [`ErrorCode::BadRequest`]; nothing is appended. +fn reject_if_current_heat_committed(state: &AppState) -> Result<(), ProtocolError> { + use gridfpv_engine::heat::HeatState; + + let (events, _cursor) = state.read()?; + // The current heat is whatever the live view is focused on (the last heat-loop transition + // or explicit selection, else the first scheduled heat). Reuse that exact derivation. + let Some(current) = crate::live_state::live_state(&events).current_heat else { + return Ok(()); // no current heat → always free to select + }; + let committed = matches!( + heat::heat_state(&events, ¤t), + Some(HeatState::Staged | HeatState::Armed | HeatState::Running) + ); + if committed { + Err(ProtocolError::new( + ErrorCode::BadRequest, + "cannot change the current heat while a heat is staged or running — \ + abort it or finish to Unofficial first", + )) + } else { + Ok(()) + } +} + +/// 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), + )) + } +} + +/// Reject a result-changing marshaling command on a heat whose folded state is **`Final`** — +/// an official result is locked; `Revert` (Final → Unofficial) is the sanctioned re-open. +/// +/// Folds with the same [`heat::heat_state`] the FSM legality checks use, so "official" here is +/// exactly the state the heat-loop (and the live view's phase badge) sees. Any other state — +/// including "never scheduled" (`None`, which the existence checks reject separately) — passes. +/// A locked heat maps to a typed [`ErrorCode::BadRequest`]; nothing is appended. +fn require_not_final(state: &AppState, heat: &HeatId) -> Result<(), ProtocolError> { + let (events, _cursor) = state.read()?; + require_not_final_in(&events, heat) +} + +/// [`require_not_final`] over an already-read log slice (the shared core, so a caller that has +/// the events in hand — e.g. the target-addressed path — doesn't re-read the log). +fn require_not_final_in(events: &[Event], heat: &HeatId) -> Result<(), ProtocolError> { + if heat::heat_state(events, heat) == Some(gridfpv_engine::heat::HeatState::Final) { + Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "heat {:?} result is official (Final) — Revert it to marshal", + heat.0 + ), + )) + } else { + Ok(()) + } +} + +/// The Final-lock check for the **target-addressed** marshaling commands (`VoidDetection` / +/// `AdjustLap` / `SplitLap` / `ThrowOutLap` / `ReverseRuling`): resolve the target's owning +/// heat ([`heat_of_offset`]) and require it not be `Final`. A target whose owning heat cannot +/// be resolved passes — there is nothing official to protect. +fn require_target_heat_not_final(state: &AppState, target: LogRef) -> Result<(), ProtocolError> { + let (events, _cursor) = state.read()?; + match heat_of_offset(&events, target) { + Some(heat) => require_not_final_in(&events, &heat), + None => Ok(()), + } +} + +/// Resolve the heat that **owns** the event at log offset `target` — the heat a ruling aimed at +/// that offset would re-score. `None` when no owning heat resolves (an out-of-range offset, or +/// an event before any heat ran). +/// +/// The routing rules mirror [`crate::app::heat_window_offsets`] (the one window fold results +/// and audits are keyed on), by what the event itself can say about where it belongs: +/// +/// - **Heat-tagged** events (heat-loop, `HeatVoided`/`PenaltyApplied`/`ProtestFiled`, a tagged +/// `LapInserted`) own their heat outright — the tag, never the position. +/// - **Offset-addressed rulings** (`DetectionVoided`/`LapAdjusted`/`LapSplit`/`LapThrownOut`/ +/// `ProtestResolved`/`RulingReversed`) belong to whichever heat *their* target is in — +/// recurse down the chain ("reverse the ruling that voided the pass…"). Targets always point +/// backwards, so the walk terminates; a malformed forward/self target resolves to `None`. +/// - **Untagged** events (raw `Pass`es, a legacy untagged `LapInserted`) attribute +/// **positionally** ([`positional_heat_at`]) — the heat whose heat-loop span contains the +/// offset, the same `active`-cursor rule `heat_window_offsets` applies. +pub(crate) fn heat_of_offset(events: &[Event], target: LogRef) -> Option { + let mut offset = target.0 as usize; + loop { + match events.get(offset)? { + Event::HeatScheduled { heat, .. } + | Event::HeatStateChanged { heat, .. } + | Event::HeatVoided { heat } + | Event::PenaltyApplied { heat, .. } + | Event::ProtestFiled { heat, .. } => return Some(heat.clone()), + Event::LapInserted { heat: Some(h), .. } => return Some(h.clone()), + // A bridge-stamped pass belongs to its TAG — the same rule `heat_window_offsets` + // scores by. Resolving it positionally let the Final lock consult the WRONG heat + // (a late tagged pass after another heat staged), accepting rulings that changed + // a Final result — or rejecting legal ones over an unrelated Final heat. + Event::Pass(p) if p.heat.is_some() => return p.heat.clone(), + Event::DetectionVoided { target } + | Event::LapAdjusted { target, .. } + | Event::LapSplit { target, .. } + | Event::LapThrownOut { target } + | Event::ProtestResolved { target, .. } + | Event::RulingReversed { target } => { + let next = target.0 as usize; + if next >= offset { + return None; // malformed chain (targets must point backwards) — bail, don't loop + } + offset = next; + } + _ => return positional_heat_at(events, offset), + } + } +} + +/// The heat **positionally active** at log offset `offset`: the heat of the latest heat-loop +/// event (`HeatScheduled` / `HeatStateChanged`) at or before it. This is the same `active` +/// cursor [`crate::app::heat_window_offsets`] walks to attribute untagged events (raw passes, +/// legacy insertions) to a heat — kept a small faithful re-walk here because the window fold +/// interleaves it with tag/target routing and run-start trimming that don't apply to a single +/// offset lookup. `None` before any heat has appeared in the log. +fn positional_heat_at(events: &[Event], offset: usize) -> Option { + let mut active: Option<&HeatId> = None; + for event in events.iter().take(offset.saturating_add(1)) { + if let Event::HeatScheduled { heat, .. } | Event::HeatStateChanged { heat, .. } = event { + active = Some(heat); + } + } + active.cloned() +} + +/// Require that `target` names a real lap **end** in the log — a raw [`Pass`](gridfpv_events::Pass) +/// *or* a marshaling event that synthesises a lap-gate pass ([`Event::LapInserted`] / +/// [`Event::LapSplit`]), since those are addressable lap ends in the corrected lap list +/// (`corrected_passes`) and so are legitimately throw-out-able. The cheap target check for +/// [`Command::ThrowOutLap`](crate::control::Command::ThrowOutLap). An out-of-range or non-lap-end +/// offset is [`ErrorCode::BadRequest`]; nothing is appended. +fn require_lap_end_target(state: &AppState, target: LogRef) -> Result<(), ProtocolError> { + let (events, _cursor) = state.read()?; + match events.get(target.0 as usize) { + Some(Event::Pass(_) | Event::LapInserted { .. } | Event::LapSplit { .. }) => Ok(()), + Some(_) => Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "log offset {} is not a lap end (a pass, inserted, or split lap)", + target.0 + ), + )), + None => Err(ProtocolError::new( + ErrorCode::BadRequest, + format!("log offset {} is out of range", target.0), + )), + } +} + +/// Require that `target` names a **lap-gate pass** in the log — raw +/// ([`Pass`](gridfpv_events::Pass)) or **synthetic** (a marshaling +/// [`LapInserted`](Event::LapInserted) / [`LapSplit`](Event::LapSplit), which the corrected-pass +/// fold treats as passes and supports voiding / re-timing — "void the void"). The cheap target +/// check for the offset-addressed marshaling commands (`VoidDetection`, `AdjustLap`). +/// +/// Raw-`Pass`-only here was a bug: the RotorHazard save-then-pull catch-up path records +/// recovered laps as `LapInserted`, so those laps' boundary refs were un-voidable — a +/// re-detection commit on such a heat bounced with "not a detected pass" (live 2026-07-03). +/// An out-of-range or non-pass offset is [`ErrorCode::BadRequest`]; nothing is appended. +/// Require that `target` is a valid [`Command::VoidDetection`] target: a lap-gate pass (raw or +/// synthetic, like [`require_pass_target`]) — or a prior [`Event::DetectionVoided`], the +/// **void-the-void RESTORE** path (the corrected-pass fold walks the chain). Restore used to be +/// unreachable through the command layer entirely: a mistaken one-click removal was permanent. +fn require_void_target(state: &AppState, target: LogRef) -> Result<(), ProtocolError> { + let (events, _cursor) = state.read()?; + match events.get(target.0 as usize) { + Some( + Event::Pass(_) + | Event::LapInserted { .. } + | Event::LapSplit { .. } + | Event::DetectionVoided { .. }, + ) => Ok(()), + Some(_) => Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "log offset {} is not a detected pass or a prior removal", + target.0 + ), + )), + None => Err(ProtocolError::new( + ErrorCode::BadRequest, + format!("log offset {} is out of range", target.0), + )), + } +} + +/// Require that `target` sits inside its owning heat's CURRENT run window. A ruling aimed at an +/// abandoned run's pass (a stale marshaling screen after a Restart/Discard) used to be accepted +/// and appended — then silently dropped by every window fold: an ack-ok correction with zero +/// effect and no audit trace. Rejecting it tells the RD their screen is stale. +fn require_target_in_current_run(state: &AppState, target: LogRef) -> Result<(), ProtocolError> { + let (events, _cursor) = state.read()?; + let Some(heat) = heat_of_offset(&events, target) else { + return Ok(()); // unowned target — the type guards already vetted it + }; + let run_start = crate::live_state::current_run_start(&events, &heat) as u64; + if target.0 < run_start { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "log offset {} belongs to an ABANDONED run of heat {:?} (it was reset since) — \ + refresh and correct the current run instead", + target.0, heat.0 + ), + )); + } + Ok(()) +} + +/// Require that no EFFECTIVE (non-reversed) [`Event::LapThrownOut`] targets `target` — shared by +/// `ThrowOutLap` (one effective throw-out per lap) and `VoidDetection` (voiding a thrown-out +/// lap's pass would leave the throw-out dangling while the merged neighbour lap COUNTS). +fn require_no_effective_throw_out(state: &AppState, target: LogRef) -> Result<(), ProtocolError> { + let (events, _cursor) = state.read()?; + let reversed: std::collections::HashSet = events + .iter() + .filter_map(|e| match e { + Event::RulingReversed { target } => Some(target.0), + _ => None, + }) + .collect(); + let standing = events.iter().enumerate().any(|(offset, e)| { + matches!(e, Event::LapThrownOut { target: t } if t.0 == target.0) + && !reversed.contains(&(offset as u64)) + }); + if standing { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "the lap ending at offset {} carries a standing throw-out — reverse it first", + target.0 + ), + )); + } + Ok(()) +} + +/// Require that `competitor` carries no EFFECTIVE (non-reversed) disqualification in `heat` — +/// one DQ status per pilot per heat (time/points penalties stack by design; a status cannot). +fn require_not_already_disqualified( + state: &AppState, + heat: &HeatId, + competitor: &gridfpv_events::CompetitorRef, +) -> Result<(), ProtocolError> { + let (events, _cursor) = state.read()?; + let reversed: std::collections::HashSet = events + .iter() + .filter_map(|e| match e { + Event::RulingReversed { target } => Some(target.0), + _ => None, + }) + .collect(); + let standing = events.iter().enumerate().any(|(offset, e)| { + matches!( + e, + Event::PenaltyApplied { + heat: h, + competitor: c, + penalty: gridfpv_events::Penalty::Disqualify { .. }, + } if h == heat && c == competitor + ) && !reversed.contains(&(offset as u64)) + }); + if standing { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "competitor {:?} is already disqualified in heat {:?} — reverse that DQ first", + competitor.0, heat.0 + ), + )); + } + Ok(()) +} + +/// Require that `at` does not COLLIDE with an existing corrected pass instant of `competitor` +/// in the target's heat: two same-instant passes fold into a ZERO-duration lap — a physically +/// impossible 0.000s that then wins best-lap and corrupts every ranking it feeds. Fuzz-caught: +/// an AdjustLap delta landing exactly on the neighbouring pass's instant. +fn require_no_instant_collision( + state: &AppState, + heat: &HeatId, + competitor: &gridfpv_events::CompetitorRef, + at: gridfpv_events::SourceTime, + exempt: Option, +) -> Result<(), ProtocolError> { + let (events, _cursor) = state.read()?; + let window = crate::app::heat_window_offsets(&events, heat); + let (surviving, _voided) = + gridfpv_projection::corrected_and_voided_passes(window.iter().map(|(o, e)| (*o, e))); + let collides = surviving.iter().any(|(offset, pass)| { + pass.competitor == *competitor && pass.at == at && exempt.is_none_or(|x| x.0 != *offset) + }); + if collides { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "{:?} already has a pass at exactly {}µs — two same-instant passes would fold \ + into an impossible zero-duration lap", + competitor.0, at.micros + ), + )); + } + Ok(()) +} + +/// The competitor a pass-target belongs to, from the raw log (for the collision guard). +fn competitor_of_pass_target( + events: &[Event], + target: LogRef, +) -> Option { + match events.get(target.0 as usize)? { + Event::Pass(p) => Some(p.competitor.clone()), + Event::LapInserted { competitor, .. } => Some(competitor.clone()), + Event::LapSplit { target, .. } => competitor_of_pass_target(events, *target), + _ => None, + } +} + +/// Require a **sane source-clock instant** for an inserted/re-timed crossing: positive, and +/// within 24h of the source epoch. A typo'd/unit-confused `at` (0, negative, or absurd) was +/// accepted and became the heat's earliest pass — hijacking `race_start` so a Timed window +/// closed before every REAL lap, silently zeroing the whole heat's scored counts. +fn require_sane_source_time(at: gridfpv_events::SourceTime) -> Result<(), ProtocolError> { + const MAX_SANE_MICROS: i64 = 24 * 60 * 60 * 1_000_000; // 24h of race-relative source clock + if at.micros < 1 || at.micros > MAX_SANE_MICROS { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "source time {}µs is out of range (must be positive and within 24h of the \ + source clock's start)", + at.micros + ), + )); + } + Ok(()) +} + +/// Require that `heat` has a CURRENT run (a `Running` since its latest reset) — the shared +/// precondition for run-scoped rulings that would otherwise be recorded yet apply to nothing. +fn require_heat_has_run(state: &AppState, heat: &HeatId) -> Result<(), ProtocolError> { + let (events, _cursor) = state.read()?; + if crate::live_state::current_run_start(&events, heat) >= events.len() { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "heat {:?} has no run yet — there is nothing to rule on", + heat.0 + ), + )); + } + Ok(()) +} + +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(_) | Event::LapInserted { .. } | Event::LapSplit { .. }) => 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), + )), + } +} + +/// Require that `target` names a **reversible ruling** in the log — the cheap target check for the +/// generalized [`Command::ReverseRuling`](crate::control::Command::ReverseRuling) (marshaling Slice +/// 6). A ruling is a [`PenaltyApplied`](Event::PenaltyApplied) (DQ / time / points), a +/// [`LapThrownOut`](Event::LapThrownOut), a [`ProtestResolved`](Event::ProtestResolved), or a +/// [`HeatVoided`](Event::HeatVoided). An out-of-range or non-ruling offset is +/// [`ErrorCode::BadRequest`]; nothing is appended. +fn require_ruling_target(state: &AppState, target: LogRef) -> Result<(), ProtocolError> { + let (events, _cursor) = state.read()?; + match events.get(target.0 as usize) { + Some( + Event::PenaltyApplied { .. } + | Event::LapThrownOut { .. } + | Event::ProtestResolved { .. } + | Event::HeatVoided { .. }, + ) => Ok(()), + Some(_) => Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "log offset {} is not a reversible ruling (penalty, throw-out, protest resolution, or heat-void)", + target.0 + ), + )), + None => Err(ProtocolError::new( + ErrorCode::BadRequest, + format!("log offset {} is out of range", target.0), + )), + } +} + +/// Count a heat's **open protests** (release-hardening P1-4): [`Event::ProtestFiled`] facts for +/// `heat` that have no *effective* resolution. +/// +/// A protest (filed at offset `f`) is closed by a [`Event::ProtestResolved`] whose `target` is `f` +/// — **unless** that resolution was itself reversed by a [`Event::RulingReversed`] (the structural +/// "void the void"), which re-opens the protest. So the open set is: filed-for-this-heat minus the +/// filings that carry a non-reversed resolution. +/// +/// This is **the** open-protest predicate: it gates the manual [`Command::Finalize`] here *and* +/// the runtime's auto-official driver (`spawn_auto_official_driver` in the app crate, issue #338) +/// — both finalize paths must agree on what "still contested" means, so the definition lives in +/// exactly one place. `pub` for that reuse. +pub fn open_protest_count(events: &[Event], heat: &HeatId) -> usize { + use std::collections::HashSet; + // Ruling offsets reversed by a `RulingReversed` (a reversed protest-resolution re-opens it). + let reversed: HashSet = events + .iter() + .filter_map(|e| match e { + Event::RulingReversed { target } => Some(target.0), + _ => None, + }) + .collect(); + // Filing offsets that have an effective (non-reversed) resolution. + let resolved: HashSet = events + .iter() + .enumerate() + .filter_map(|(offset, e)| match e { + Event::ProtestResolved { target, .. } if !reversed.contains(&(offset as u64)) => { + Some(target.0) + } + _ => None, + }) + .collect(); + // A protest contests a specific run's result: one filed before a RESET (Abort / Restart / + // Discard — the heat re-races anyway) dies with the abandoned run. Without this boundary, + // a pre-Restart protest blocked Finalize forever while the run-windowed audit view showed + // no protest at all — an RD deadlock. The boundary is the latest reset (not the run start): + // a protest filed before the re-run's `Running` still counts against the new result. + let reset_boundary = events + .iter() + .enumerate() + .filter_map(|(i, e)| match e { + Event::HeatStateChanged { + heat: h, + transition: + HeatTransition::Aborted | HeatTransition::Restarted | HeatTransition::Discarded, + } if h == heat => Some(i as u64 + 1), + _ => None, + }) + .next_back() + .unwrap_or(0); + // Filed protests for this heat since the latest reset, with no effective resolution. + events + .iter() + .enumerate() + .filter(|(offset, e)| { + matches!(e, Event::ProtestFiled { heat: h, .. } if h == heat) + && *offset as u64 >= reset_boundary + && !resolved.contains(&(*offset as u64)) + }) + .count() +} + +/// Require that the `ProtestFiled` at `target` has **no effective (non-reversed) resolution** +/// yet — the double-resolve guard for [`Command::ResolveProtest`]. A filing whose resolution was +/// reversed counts as unresolved again (re-deciding it is exactly the reversal's purpose). +fn require_protest_unresolved(state: &AppState, target: LogRef) -> Result<(), ProtocolError> { + let (events, _cursor) = state.read()?; + let reversed: std::collections::HashSet = events + .iter() + .filter_map(|e| match e { + Event::RulingReversed { target } => Some(target.0), + _ => None, + }) + .collect(); + let already = events.iter().enumerate().any(|(offset, e)| { + matches!(e, Event::ProtestResolved { target: t, .. } if t.0 == target.0) + && !reversed.contains(&(offset as u64)) + }); + if already { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "protest at offset {} is already resolved — reverse that resolution to re-decide it", + target.0 + ), + )); + } + Ok(()) +} + +/// Require that `heat` is not already **effectively voided** (a [`Event::HeatVoided`] with no +/// [`Event::RulingReversed`] undoing it) — the double-void guard for [`Command::VoidHeat`]. +fn require_heat_not_voided(state: &AppState, heat: &HeatId) -> Result<(), ProtocolError> { + let (events, _cursor) = state.read()?; + let reversed: std::collections::HashSet = events + .iter() + .filter_map(|e| match e { + Event::RulingReversed { target } => Some(target.0), + _ => None, + }) + .collect(); + let run_start = crate::live_state::current_run_start(&events, heat) as u64; + let voided = events.iter().enumerate().any(|(offset, e)| { + matches!(e, Event::HeatVoided { heat: h } if h == heat) + && offset as u64 >= run_start + && !reversed.contains(&(offset as u64)) + }); + if voided { + return Err(ProtocolError::new( + ErrorCode::BadRequest, + format!( + "heat {:?} is already voided — reverse the existing void first", + heat.0 + ), + )); + } + Ok(()) +} + +/// Require that `target` names a real [`Event::ProtestFiled`] in the log — the cheap target check +/// for [`Command::ResolveProtest`](crate::control::Command::ResolveProtest). An out-of-range or +/// non-protest offset is [`ErrorCode::BadRequest`]; nothing is appended. +fn require_protest_target(state: &AppState, target: LogRef) -> Result<(), ProtocolError> { + let (events, _cursor) = state.read()?; + match events.get(target.0 as usize) { + Some(Event::ProtestFiled { .. }) => Ok(()), + Some(_) => Err(ProtocolError::new( + ErrorCode::BadRequest, + format!("log offset {} is not a filed protest", 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())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + 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, + heat: None, + }) + } + + /// (a) A legal Stage→Start→SkipCountdown→ForceEnd→Finalize sequence acks ok and appends the + /// matching `HeatStateChanged` events in order. (The Armed→Running / Running→Unofficial steps + /// are normally runtime-appended; here the overrides drive them through the command path.) + #[test] + fn legal_heat_loop_sequence_appends_transitions_and_acks_ok() { + let state = scheduled_state(); + let steps = [ + (Command::Stage { heat: heat() }, HeatTransition::Staged), + (Command::Start { heat: heat() }, HeatTransition::Armed), + ( + Command::SkipCountdown { heat: heat() }, + HeatTransition::Running, + ), + (Command::ForceEnd { heat: heat() }, HeatTransition::Finished), + ( + Command::Finalize { heat: heat() }, + HeatTransition::Finalized, + ), + ]; + 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; the free-text path leaves the + /// additive class/round/frequencies absent. + #[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(), + class: None, + round: None, + frequencies: vec![], + label: None, + }, + ); + assert!(ack.ok); + let (events, _) = state.read().unwrap(); + assert!(events.iter().any(|e| matches!( + e, + Event::HeatScheduled { heat: h, lineup: l, class: None, round: None, frequencies, label: None } + if *h == heat() && *l == lineup && frequencies.is_empty() + ))); + } + + /// A `ScheduleHeat` carrying class/round/frequencies threads them straight into the + /// emitted `HeatScheduled` (the scheduler path). + #[test] + fn schedule_heat_carries_class_round_and_frequencies() { + use gridfpv_events::{ClassId, RoundId}; + let state = AppState::new(InMemoryLog::default()); + let lineup = vec![CompetitorRef("A".into()), CompetitorRef("B".into())]; + let freqs = vec![ + (CompetitorRef("A".into()), 5658u16), + (CompetitorRef("B".into()), 5695u16), + ]; + let ack = apply_command( + &state, + Command::ScheduleHeat { + heat: heat(), + lineup: lineup.clone(), + class: Some(ClassId("open".into())), + round: Some(RoundId("r1".into())), + frequencies: freqs.clone(), + label: None, + }, + ); + assert!(ack.ok, "got {ack:?}"); + let (events, _) = state.read().unwrap(); + assert!(events.iter().any(|e| matches!( + e, + Event::HeatScheduled { heat: h, class: Some(c), round: Some(r), frequencies, .. } + if *h == heat() + && *c == ClassId("open".into()) + && *r == RoundId("r1".into()) + && *frequencies == freqs + ))); + } + + /// A `ScheduleHeat` carrying a custom `label` persists it on the emitted `HeatScheduled` + /// (the build-heat custom-name path); a generator/free-text heat leaves it `None`. + #[test] + fn schedule_heat_carries_the_custom_label() { + let state = AppState::new(InMemoryLog::default()); + let lineup = vec![CompetitorRef("A".into())]; + let ack = apply_command( + &state, + Command::ScheduleHeat { + heat: heat(), + lineup: lineup.clone(), + class: None, + round: None, + frequencies: vec![], + label: Some("Featured Heat".into()), + }, + ); + assert!(ack.ok, "got {ack:?}"); + let (events, _) = state.read().unwrap(); + assert!(events.iter().any(|e| matches!( + e, + Event::HeatScheduled { heat: h, label: Some(l), .. } + if *h == heat() && l == "Featured Heat" + ))); + } + + /// A `ScheduleHeat` seating the same competitor twice is rejected with a typed `BadRequest` + /// and appends nothing (#335) — a duplicate ref would merge two seats into one lap stream. + #[test] + fn schedule_heat_rejects_a_duplicate_competitor_in_the_lineup() { + let state = AppState::new(InMemoryLog::default()); + let ack = apply_command( + &state, + Command::ScheduleHeat { + heat: heat(), + lineup: vec![CompetitorRef("A".into()), CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + ); + assert!(!ack.ok, "a duplicate lineup entry must be rejected"); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + let (events, _) = state.read().unwrap(); + assert!(events.is_empty(), "a rejected ScheduleHeat appends nothing"); + } + + /// `ScheduleHeat` on an id that already exists is rejected (#335 / #341): the fold re-seeds a + /// repeated `HeatScheduled` back to `Scheduled`, so accepting the duplicate would silently + /// reset a **Final** heat and orphan its result. The heat must stay Final; a re-run goes + /// through `Discard`/`Restart`, never a re-schedule. + #[test] + fn schedule_heat_rejects_an_existing_heat_id() { + use gridfpv_engine::heat::{HeatState, heat_state}; + + // q-1 driven all the way to Final. + let state = drive_current_to(&[ + HeatTransition::Staged, + HeatTransition::Armed, + HeatTransition::Running, + HeatTransition::Finished, + HeatTransition::Finalized, + ]); + let (before, _) = state.read().unwrap(); + assert_eq!(heat_state(&before, &heat()), Some(HeatState::Final)); + + let ack = apply_command( + &state, + Command::ScheduleHeat { + heat: heat(), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + ); + assert!(!ack.ok, "re-scheduling an existing id must be rejected"); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + + // Nothing appended; the heat is still Final — NOT re-seeded to Scheduled. + let (after, _) = state.read().unwrap(); + assert_eq!(before.len(), after.len(), "nothing was appended"); + assert_eq!( + heat_state(&after, &heat()), + Some(HeatState::Final), + "the finished heat keeps its state" + ); + + // A merely-Scheduled heat is protected the same way (only genuinely-new ids pass). + let state = scheduled_state(); + let ack = apply_command( + &state, + Command::ScheduleHeat { + heat: heat(), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + ); + assert!(!ack.ok, "a scheduled id is not a fresh id either"); + } + + /// `SetCurrentHeat` validates the heat exists, then appends a `CurrentHeatSelected` — and the + /// live `current_heat` derivation follows it on replay (event-sourced / deterministic). + #[test] + fn set_current_heat_validates_appends_and_drives_the_live_current_heat() { + use crate::live_state::live_state; + + // Two scheduled heats; q-1 is the first (the default current heat before any selection). + let mut log = InMemoryLog::default(); + for id in ["q-1", "q-2"] { + EventLog::append( + &mut log, + Event::HeatScheduled { + heat: HeatId(id.into()), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + } + let state = AppState::new(log); + + // Selecting q-2 acks ok and appends the CurrentHeatSelected. + let ack = apply_command( + &state, + Command::SetCurrentHeat { + heat: HeatId("q-2".into()), + }, + ); + assert!(ack.ok, "got {ack:?}"); + let (events, _) = state.read().unwrap(); + assert!(events.iter().any(|e| matches!( + e, + Event::CurrentHeatSelected { heat } if *heat == HeatId("q-2".into()) + ))); + + // The live state now follows the selection (replay-deterministic). + assert_eq!(live_state(&events).current_heat, Some(HeatId("q-2".into()))); + } + + /// `SetCurrentHeat` on a heat that was never scheduled is an `UnknownScope` rejection (no append). + #[test] + fn set_current_heat_on_unknown_heat_is_rejected() { + let state = scheduled_state(); + let (before, _) = state.read().unwrap(); + let ack = apply_command( + &state, + Command::SetCurrentHeat { + heat: HeatId("does-not-exist".into()), + }, + ); + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().code, ErrorCode::UnknownScope); + let (after, _) = state.read().unwrap(); + assert_eq!( + before.len(), + after.len(), + "a rejected select appends nothing" + ); + } + + /// A log with two scheduled heats (`q-1`, `q-2`); `q-1` is the default current heat. + fn two_heats_state() -> AppState { + let mut log = InMemoryLog::default(); + for id in ["q-1", "q-2"] { + EventLog::append( + &mut log, + Event::HeatScheduled { + heat: HeatId(id.into()), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + } + AppState::new(log) + } + + /// Drive `q-1` to the given terminal transition through the command path (so the FSM + /// legality is honoured), then return the state. + fn drive_current_to(transitions: &[HeatTransition]) -> AppState { + let state = two_heats_state(); + let commands: &[Command] = &[ + Command::Stage { + heat: HeatId("q-1".into()), + }, + Command::Start { + heat: HeatId("q-1".into()), + }, + Command::SkipCountdown { + heat: HeatId("q-1".into()), + }, + Command::ForceEnd { + heat: HeatId("q-1".into()), + }, + Command::Finalize { + heat: HeatId("q-1".into()), + }, + ]; + // Map the requested transition path to the matching prefix of the loop commands. + let steps = match transitions { + [HeatTransition::Staged] => 1, + [HeatTransition::Staged, HeatTransition::Armed] => 2, + [.., HeatTransition::Running] => 3, + [.., HeatTransition::Finished] => 4, + [.., HeatTransition::Finalized] => 5, + _ => panic!("unsupported transition path {transitions:?}"), + }; + for command in &commands[..steps] { + let ack = apply_command(&state, command.clone()); + assert!(ack.ok, "driving q-1 failed: {ack:?}"); + } + state + } + + /// `SetCurrentHeat` is **rejected** with a typed `BadRequest` while the current heat is in + /// a committed phase (`Staged`/`Armed`/`Running`) — abort it or finish first. Nothing is + /// appended. + #[test] + fn set_current_heat_is_rejected_while_current_is_staged_armed_or_running() { + let paths: &[&[HeatTransition]] = &[ + &[HeatTransition::Staged], + &[HeatTransition::Staged, HeatTransition::Armed], + &[ + HeatTransition::Staged, + HeatTransition::Armed, + HeatTransition::Running, + ], + ]; + for path in paths { + let state = drive_current_to(path); + let (before, _) = state.read().unwrap(); + let ack = apply_command( + &state, + Command::SetCurrentHeat { + heat: HeatId("q-2".into()), + }, + ); + assert!(!ack.ok, "{path:?}: expected rejection, got {ack:?}"); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest, "{path:?}"); + let (after, _) = state.read().unwrap(); + assert_eq!(before.len(), after.len(), "{path:?}: nothing appended"); + } + } + + /// `SetCurrentHeat` is **accepted** when the current heat is `Scheduled`, `Unofficial`, or + /// `Final` — and when there is no current heat — and the selection replays deterministically. + #[test] + fn set_current_heat_is_accepted_when_current_is_idle_or_scored() { + use crate::live_state::live_state; + + // Scheduled (the default current heat before any transition). + let state = two_heats_state(); + let ack = apply_command( + &state, + Command::SetCurrentHeat { + heat: HeatId("q-2".into()), + }, + ); + assert!(ack.ok, "scheduled current must allow a switch: {ack:?}"); + + // Unofficial (Running → Finished) and Final (→ Finalized): each allows the switch. + for path in [ + &[ + HeatTransition::Staged, + HeatTransition::Armed, + HeatTransition::Running, + HeatTransition::Finished, + ][..], + &[ + HeatTransition::Staged, + HeatTransition::Armed, + HeatTransition::Running, + HeatTransition::Finished, + HeatTransition::Finalized, + ][..], + ] { + let state = drive_current_to(path); + let ack = apply_command( + &state, + Command::SetCurrentHeat { + heat: HeatId("q-2".into()), + }, + ); + assert!( + ack.ok, + "{path:?}: a scored current must allow a switch: {ack:?}" + ); + let (events, _) = state.read().unwrap(); + // Replay-deterministic: the live current heat follows the accepted selection. + assert_eq!( + live_state(&events).current_heat, + Some(HeatId("q-2".into())), + "{path:?}" + ); + } + + // No current heat (empty log): a select still only fails the existence check, not the lock. + let empty = AppState::new(InMemoryLog::default()); + let ack = apply_command( + &empty, + Command::SetCurrentHeat { + heat: HeatId("q-1".into()), + }, + ); + // The heat does not exist, so this is UnknownScope — *not* the BadRequest lock. + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().code, ErrorCode::UnknownScope); + } + + /// After aborting a staged current heat back to `Scheduled`, the picker is free again — the + /// switch is accepted (the abort-to-switch path). + #[test] + fn set_current_heat_is_accepted_after_abort_back_to_scheduled() { + let state = drive_current_to(&[HeatTransition::Staged]); + // While staged it is locked. + let ack = apply_command( + &state, + Command::SetCurrentHeat { + heat: HeatId("q-2".into()), + }, + ); + assert!(!ack.ok, "staged current is locked"); + + // Abort q-1 back to Scheduled, then the switch is accepted. + let ack = apply_command( + &state, + Command::Abort { + heat: HeatId("q-1".into()), + }, + ); + assert!(ack.ok, "abort failed: {ack:?}"); + let ack = apply_command( + &state, + Command::SetCurrentHeat { + heat: HeatId("q-2".into()), + }, + ); + assert!(ack.ok, "after abort the switch must be allowed: {ack:?}"); + } + + /// (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: penalty.clone(), + }, + ); + 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![], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + 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); + } + + /// `VoidDetection` / `AdjustLap` accept **synthetic** passes too: the RH save-then-pull + /// catch-up path records recovered laps as `LapInserted`, and the corrected-pass fold fully + /// supports voiding / re-timing them ("void the void") — a raw-`Pass`-only validator made + /// those laps un-marshalable, bouncing re-detection commits with "not a detected pass". + #[test] + fn void_and_adjust_accept_synthetic_pass_targets() { + let mut log = InMemoryLog::default(); + // offset 0: a marshaling-inserted lap pass (the RH catch-up shape — untagged). + EventLog::append( + &mut log, + Event::LapInserted { + adapter: AdapterId("rh-1".into()), + competitor: CompetitorRef("A".into()), + at: SourceTime::from_micros(5_000_000), + heat: None, + }, + None, + ) + .unwrap(); + let state = AppState::new(log); + + // Voiding the inserted lap succeeds and appends the ruling. + let ack = apply_command(&state, Command::VoidDetection { target: LogRef(0) }); + assert!(ack.ok, "voiding a LapInserted must be accepted: {ack:?}"); + // Re-timing it succeeds too. + let ack = apply_command( + &state, + Command::AdjustLap { + target: LogRef(0), + at: SourceTime::from_micros(5_200_000), + }, + ); + assert!(ack.ok, "adjusting a LapInserted must be accepted: {ack:?}"); + let (events, _) = state.read().unwrap(); + assert!( + events + .iter() + .any(|e| matches!(e, Event::DetectionVoided { target } if *target == LogRef(0))) + ); + } + + /// `SplitLap` validates the target is a real pass (the lap's ending pass), then appends + /// `LapSplit`. A non-pass / out-of-range target is rejected and appends nothing. + #[test] + fn split_lap_validates_target_and_appends() { + let mut log = InMemoryLog::default(); + EventLog::append(&mut log, pass("A", 1_000_000, 1), None).unwrap(); // offset 0: a pass + EventLog::append( + &mut log, + Event::HeatScheduled { + heat: heat(), + lineup: vec![], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); // offset 1: not a pass + let state = AppState::new(log); + + let at = SourceTime::from_micros(500_000); + let ack = apply_command( + &state, + Command::SplitLap { + target: LogRef(0), + at, + }, + ); + assert!(ack.ok, "got {ack:?}"); + let (events, _) = state.read().unwrap(); + assert!(events.iter().any( + |e| matches!(e, Event::LapSplit { target, at: a } if *target == LogRef(0) && *a == at) + )); + + // A non-pass target is rejected, nothing appended. + let (before, _) = state.read().unwrap(); + let ack = apply_command( + &state, + Command::SplitLap { + target: LogRef(1), + at, + }, + ); + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + let (after, _) = state.read().unwrap(); + assert_eq!(before.len(), after.len()); + } + + /// `ReverseRuling` validates the target is a real `PenaltyApplied`, then appends + /// `RulingReversed`. A non-penalty / out-of-range target is rejected and appends nothing. + #[test] + fn reverse_ruling_validates_penalty_target_and_appends() { + let state = scheduled_state(); // offset 0: HeatScheduled + // Apply a penalty so there is a real ruling to reverse (lands at offset 1). + let ack = apply_command( + &state, + Command::ApplyPenalty { + heat: heat(), + competitor: CompetitorRef("A".into()), + penalty: Penalty::Disqualify { reason: None }, + }, + ); + assert!(ack.ok, "got {ack:?}"); + + // Reversing the penalty at offset 1 succeeds and appends `RulingReversed`. + let ack = apply_command(&state, Command::ReverseRuling { target: LogRef(1) }); + assert!(ack.ok, "got {ack:?}"); + let (events, _) = state.read().unwrap(); + assert!( + events + .iter() + .any(|e| matches!(e, Event::RulingReversed { target } if *target == LogRef(1))) + ); + + // Reversing a non-penalty (the HeatScheduled at offset 0) is rejected, nothing appended. + let (before, _) = state.read().unwrap(); + let ack = apply_command(&state, Command::ReverseRuling { target: LogRef(0) }); + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + // And an out-of-range target is rejected too. + let ack = apply_command( + &state, + Command::ReverseRuling { + target: LogRef(999), + }, + ); + assert!(!ack.ok); + let (after, _) = state.read().unwrap(); + assert_eq!(before.len(), after.len()); + } + + // --- Slice 6 adjudication commands --------------------------------------------------------- + + /// `DeductPoints` appends a `PenaltyApplied { PointsDeducted }` for the competitor. + #[test] + fn deduct_points_appends_a_points_penalty() { + let state = scheduled_state(); + let ack = apply_command( + &state, + Command::DeductPoints { + heat: heat(), + competitor: CompetitorRef("A".into()), + points: 5, + }, + ); + assert!(ack.ok, "got {ack:?}"); + let (events, _) = state.read().unwrap(); + assert!(events.iter().any(|e| matches!( + e, + Event::PenaltyApplied { competitor, penalty: Penalty::PointsDeducted { points }, .. } + if *competitor == CompetitorRef("A".into()) && *points == 5 + ))); + } + + /// `ThrowOutLap` validates the target is a real pass, then appends `LapThrownOut`. A non-pass + /// or out-of-range target is rejected and appends nothing. + #[test] + fn throw_out_lap_validates_pass_target_and_appends() { + let mut log = InMemoryLog::default(); + EventLog::append(&mut log, pass("A", 1_000_000, 1), None).unwrap(); // offset 0: a pass + EventLog::append( + &mut log, + Event::HeatScheduled { + heat: heat(), + lineup: vec![], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); // offset 1: not a pass + let state = AppState::new(log); + + let ack = apply_command(&state, Command::ThrowOutLap { target: LogRef(0) }); + assert!(ack.ok, "got {ack:?}"); + let (events, _) = state.read().unwrap(); + assert!( + events + .iter() + .any(|e| matches!(e, Event::LapThrownOut { target } if *target == LogRef(0))) + ); + + // A non-pass target (the HeatScheduled at offset 1) is rejected, nothing appended. + let (before, _) = state.read().unwrap(); + let ack = apply_command(&state, Command::ThrowOutLap { 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()); + } + + /// A throw-out may target an *inserted* lap (whose `end_ref` is the `LapInserted` event's + /// offset, not a raw `Pass`) — `require_lap_end_target` accepts it. + #[test] + fn throw_out_lap_accepts_an_inserted_lap_target() { + let mut log = InMemoryLog::default(); + EventLog::append( + &mut log, + Event::LapInserted { + adapter: AdapterId("vd".into()), + competitor: CompetitorRef("A".into()), + at: SourceTime::from_micros(3_000_000), + heat: None, + }, + None, + ) + .unwrap(); // offset 0: an inserted lap (a synthetic lap end) + let state = AppState::new(log); + + let ack = apply_command(&state, Command::ThrowOutLap { target: LogRef(0) }); + assert!(ack.ok, "an inserted lap must be throw-out-able: {ack:?}"); + let (events, _) = state.read().unwrap(); + assert!( + events + .iter() + .any(|e| matches!(e, Event::LapThrownOut { target } if *target == LogRef(0))) + ); + } + + /// `FileProtest` then `ResolveProtest` append the protest pair; resolving validates the target + /// is a real `ProtestFiled`, and a non-protest / out-of-range target is rejected. + #[test] + fn file_then_resolve_protest_appends_the_pair() { + use gridfpv_events::ProtestOutcome; + let state = scheduled_state(); // offset 0: HeatScheduled + // A protest contests a RUN's result — give the heat one (the run-scoped guard). + state + .append( + Event::HeatStateChanged { + heat: heat(), + transition: HeatTransition::Running, + }, + None, + ) + .unwrap(); + + let ack = apply_command( + &state, + Command::FileProtest { + heat: heat(), + competitor: CompetitorRef("A".into()), + note: "cut the course".into(), + }, + ); + assert!(ack.ok, "got {ack:?}"); // ProtestFiled lands at offset 1 + let (events, _) = state.read().unwrap(); + assert!(events.iter().any(|e| matches!( + e, + Event::ProtestFiled { competitor, note, .. } + if *competitor == CompetitorRef("A".into()) && note == "cut the course" + ))); + + // Resolve the protest (offset 2 — after the schedule + the run). + let ack = apply_command( + &state, + Command::ResolveProtest { + target: LogRef(2), + outcome: ProtestOutcome::Upheld, + }, + ); + assert!(ack.ok, "got {ack:?}"); + let (events, _) = state.read().unwrap(); + assert!(events.iter().any(|e| matches!( + e, + Event::ProtestResolved { target, outcome: ProtestOutcome::Upheld } + if *target == LogRef(2) + ))); + + // Resolving a non-protest (the HeatScheduled at offset 0) is rejected, nothing appended. + let (before, _) = state.read().unwrap(); + let ack = apply_command( + &state, + Command::ResolveProtest { + target: LogRef(0), + outcome: ProtestOutcome::Denied, + }, + ); + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + let (after, _) = state.read().unwrap(); + assert_eq!(before.len(), after.len()); + } + + /// P1-4: `Finalize` is **gated on open protests** — a heat with a filed, unresolved protest + /// cannot be finalized (rejected, appends nothing); once the protest is resolved, finalize is + /// allowed. + #[test] + fn finalize_is_gated_on_open_protests() { + use gridfpv_events::ProtestOutcome; + let state = scheduled_state(); // offset 0: HeatScheduled q-1 + // Drive q-1 to Unofficial (finalizable). + for cmd in [ + Command::Stage { heat: heat() }, + Command::Start { heat: heat() }, + Command::SkipCountdown { heat: heat() }, + Command::ForceEnd { heat: heat() }, + ] { + assert!(apply_command(&state, cmd).ok); + } + + // File a protest against the heat. + assert!( + apply_command( + &state, + Command::FileProtest { + heat: heat(), + competitor: CompetitorRef("A".into()), + note: "contested".into(), + }, + ) + .ok + ); + let (before, _) = state.read().unwrap(); + let filed = before + .iter() + .position(|e| matches!(e, Event::ProtestFiled { .. })) + .expect("protest filed") as u64; + + // Finalize is rejected while the protest is open — and appends nothing. + let ack = apply_command(&state, Command::Finalize { heat: heat() }); + assert!(!ack.ok, "finalize must be blocked by an open protest"); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + let (after, _) = state.read().unwrap(); + assert_eq!( + before.len(), + after.len(), + "blocked finalize appends nothing" + ); + + // Resolve the protest, then finalize is allowed. + assert!( + apply_command( + &state, + Command::ResolveProtest { + target: LogRef(filed), + outcome: ProtestOutcome::Denied, + }, + ) + .ok + ); + let ack = apply_command(&state, Command::Finalize { heat: heat() }); + assert!(ack.ok, "after resolution finalize is allowed: {ack:?}"); + } + + /// P1-4 unit: `open_protest_count` — a filed protest is open until an *effective* (non-reversed) + /// resolution closes it; reversing the resolution re-opens it. + #[test] + fn open_protest_count_tracks_resolution_and_reversal() { + use gridfpv_events::ProtestOutcome; + let h = heat(); + // A protest contests a RUN's result, so the fixture gives the heat a run first + // (offset 0: Running) — the predicate windows from the current run. + let running = Event::HeatStateChanged { + heat: h.clone(), + transition: HeatTransition::Running, + }; + let filed = Event::ProtestFiled { + heat: h.clone(), + competitor: CompetitorRef("A".into()), + note: "x".into(), + }; + // A filing against the run → open. + let base = vec![running.clone(), filed.clone()]; + assert_eq!(open_protest_count(&base, &h), 1); + // Filing + resolution → closed. + let resolved = vec![ + running.clone(), + filed.clone(), + Event::ProtestResolved { + target: LogRef(1), + outcome: ProtestOutcome::Denied, + }, + ]; + assert_eq!(open_protest_count(&resolved, &h), 0); + // Reversing the resolution (at offset 2) re-opens the protest. + let mut reversed = resolved.clone(); + reversed.push(Event::RulingReversed { target: LogRef(2) }); + assert_eq!(open_protest_count(&reversed, &h), 1); + // A protest for a DIFFERENT heat doesn't count. + assert_eq!(open_protest_count(&base, &HeatId("other".into())), 0); + } + + #[test] + fn deep_lap_guards_reject_the_footgun_sequences() { + use gridfpv_events::Penalty; + // One raced heat with two passes: 0 sched, then Stage/Start/Skip drive it Running. + let state = scheduled_state(); + for cmd in [ + Command::Stage { heat: heat() }, + Command::Start { heat: heat() }, + Command::SkipCountdown { heat: heat() }, + ] { + assert!(apply_command(&state, cmd).ok); + } + let p1 = state.append(pass("A", 1_000_000, 1), None).unwrap(); + let p2 = state.append(pass("A", 4_000_000, 2), None).unwrap(); + + // Degenerate source times are rejected (the race_start hijack). + for at in [0i64, -5, 90 * 60 * 60 * 1_000_000] { + let ack = apply_command( + &state, + Command::AdjustLap { + target: LogRef(p2), + at: SourceTime::from_micros(at), + }, + ); + assert!(!ack.ok, "at={at} must be rejected"); + } + + // ONE effective throw-out per lap; reversing it re-arms. + assert!(apply_command(&state, Command::ThrowOutLap { target: LogRef(p2) }).ok); + let dup = apply_command(&state, Command::ThrowOutLap { target: LogRef(p2) }); + assert!(!dup.ok, "stacked throw-out must be rejected"); + // Voiding the thrown-out lap's pass is rejected while the ruling stands. + let void_over_throw = apply_command(&state, Command::VoidDetection { target: LogRef(p2) }); + assert!( + !void_over_throw.ok, + "void over a standing throw-out must be rejected" + ); + let (events, _) = state.read().unwrap(); + let throw_offset = events + .iter() + .enumerate() + .find_map(|(i, e)| matches!(e, Event::LapThrownOut { .. }).then_some(i as u64)) + .unwrap(); + assert!( + apply_command( + &state, + Command::ReverseRuling { + target: LogRef(throw_offset) + } + ) + .ok + ); + assert!( + apply_command(&state, Command::ThrowOutLap { target: LogRef(p2) }).ok, + "after the reversal a fresh throw-out is legal again" + ); + + // ONE effective DQ per pilot per heat; time penalties still stack. + let dq = |state: &AppState| { + apply_command( + state, + Command::ApplyPenalty { + heat: heat(), + competitor: CompetitorRef("A".into()), + penalty: Penalty::Disqualify { reason: None }, + }, + ) + }; + assert!(dq(&state).ok); + assert!(!dq(&state).ok, "stacked DQ must be rejected"); + for _ in 0..2 { + assert!( + apply_command( + &state, + Command::ApplyPenalty { + heat: heat(), + competitor: CompetitorRef("A".into()), + penalty: Penalty::TimeAdded { micros: 1_000_000 }, + } + ) + .ok, + "time penalties stack by design" + ); + } + + // Restore (void-the-void) is a sanctioned command path now. + assert!(apply_command(&state, Command::VoidDetection { target: LogRef(p1) }).ok); + let (events, _) = state.read().unwrap(); + let void_offset = events + .iter() + .enumerate() + .find_map(|(i, e)| matches!(e, Event::DetectionVoided { .. }).then_some(i as u64)) + .unwrap(); + assert!( + apply_command( + &state, + Command::VoidDetection { + target: LogRef(void_offset) + } + ) + .ok, + "void-the-void (restore) must be accepted" + ); + + // An adjust landing EXACTLY on another pass's instant is rejected (a zero-duration + // lap would fold — an impossible 0.000s best lap corrupting every ranking). + let collide = apply_command( + &state, + Command::AdjustLap { + target: LogRef(p2), + at: SourceTime::from_micros(1_000_000), // p1's exact instant + }, + ); + assert!(!collide.ok, "same-instant adjust must be rejected"); + // Re-asserting the pass's OWN instant is fine (exempt). + assert!( + apply_command( + &state, + Command::AdjustLap { + target: LogRef(p2), + at: SourceTime::from_micros(4_000_000), + } + ) + .ok + ); + + // A stale-run target is rejected after a Restart (the abandoned-run trap). + assert!(apply_command(&state, Command::ForceEnd { heat: heat() }).ok); + assert!(apply_command(&state, Command::Restart { heat: heat() }).ok); + let stale = apply_command(&state, Command::VoidDetection { target: LogRef(p2) }); + assert!( + !stale.ok, + "a ruling on an abandoned run's pass must be rejected" + ); + assert!( + stale.error.unwrap().message.contains("ABANDONED"), + "the rejection explains the staleness" + ); + + // Protests + heat-voids need a run: the reset heat (Scheduled again) rejects both. + let protest = apply_command( + &state, + Command::FileProtest { + heat: heat(), + competitor: CompetitorRef("A".into()), + note: "pre-run".into(), + }, + ); + assert!(!protest.ok, "a protest needs a run to contest"); + let void_heat = apply_command(&state, Command::VoidHeat { heat: heat() }); + assert!(!void_heat.ok, "a heat-void needs a run to void"); + } + + #[test] + fn a_protest_dies_with_the_run_it_contests() { + // Filed against run 1, then the heat is Restarted (it re-races anyway): the protest + // must NOT keep gating Finalize — the old whole-log predicate deadlocked the RD + // (Finalize rejected over a protest no run-windowed view could even show). + let h = heat(); + let running = |t| Event::HeatStateChanged { + heat: h.clone(), + transition: t, + }; + let events = vec![ + running(HeatTransition::Running), + Event::ProtestFiled { + heat: h.clone(), + competitor: CompetitorRef("A".into()), + note: "run-1 grievance".into(), + }, + running(HeatTransition::Finished), + running(HeatTransition::Restarted), + running(HeatTransition::Running), // the re-run + ]; + assert_eq!( + open_protest_count(&events, &h), + 0, + "a pre-restart protest must not block the re-run's Finalize" + ); + // A protest filed against the RE-RUN is open as usual. + let mut with_new = events.clone(); + with_new.push(Event::ProtestFiled { + heat: h.clone(), + competitor: CompetitorRef("A".into()), + note: "run-2 grievance".into(), + }); + assert_eq!(open_protest_count(&with_new, &h), 1); + } + + /// The **generalized** `ReverseRuling` (Slice 6) accepts a throw-out, a protest resolution, and + /// a heat-void as targets — not just a penalty — and still rejects a non-ruling. + #[test] + fn reverse_ruling_accepts_any_ruling_target() { + let mut log = InMemoryLog::default(); + // offset 0: the heat; offset 1: its run; offset 2: a pass INSIDE the run (rulings + // are run-scoped now — a target before the run's start is rejected as stale). + EventLog::append( + &mut log, + Event::HeatScheduled { + heat: heat(), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + EventLog::append( + &mut log, + Event::HeatStateChanged { + heat: heat(), + transition: HeatTransition::Running, + }, + None, + ) + .unwrap(); + EventLog::append(&mut log, pass("A", 1_000_000, 1), None).unwrap(); + let state = AppState::new(log); + + // Append a throw-out (offset 3), a heat-void (offset 4) — both reversible rulings. + assert!(apply_command(&state, Command::ThrowOutLap { target: LogRef(2) }).ok); + assert!(apply_command(&state, Command::VoidHeat { heat: heat() }).ok); + + for target in [LogRef(3), LogRef(4)] { + let ack = apply_command(&state, Command::ReverseRuling { target }); + assert!(ack.ok, "reversing ruling at {target:?} failed: {ack:?}"); + } + // But reversing the pass at offset 2 (not a ruling) is rejected. + let ack = apply_command(&state, Command::ReverseRuling { target: LogRef(2) }); + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + } + + // ── The official-result lock: no result-changing ruling on a Final heat ────────────────── + + /// The exact rejection every locked command answers on a Final heat. + fn final_lock_message() -> String { + "heat \"q-1\" result is official (Final) — Revert it to marshal".to_string() + } + + /// Drive `q-1` (already scheduled) to **Final** on the bare `apply_command` path, banking one + /// real pass while Running. Returns the state and the pass's global offset (a valid target + /// for the offset-addressed commands). + fn final_state_with_pass() -> (AppState, u64) { + let state = scheduled_state(); + for cmd in [ + Command::Stage { heat: heat() }, + Command::Start { heat: heat() }, + Command::SkipCountdown { heat: heat() }, + ] { + let ack = apply_command(&state, cmd.clone()); + assert!(ack.ok, "driving q-1 to Running via {cmd:?}: {ack:?}"); + } + let pass_offset = state.append(pass("A", 1_000_000, 1), None).unwrap(); + for cmd in [ + Command::ForceEnd { heat: heat() }, + Command::Finalize { heat: heat() }, + ] { + let ack = apply_command(&state, cmd.clone()); + assert!(ack.ok, "driving q-1 to Final via {cmd:?}: {ack:?}"); + } + (state, pass_offset) + } + + /// Every result-changing marshaling command is rejected on a **Final** heat with the exact + /// "official — Revert it to marshal" BadRequest (appending nothing), and the SAME command is + /// accepted after `Revert` re-opens the result to Unofficial. + #[test] + fn result_changing_commands_are_locked_on_a_final_heat_until_revert() { + let (state, pass_offset) = final_state_with_pass(); + let commands = [ + Command::VoidHeat { heat: heat() }, + Command::ApplyPenalty { + heat: heat(), + competitor: CompetitorRef("A".into()), + penalty: Penalty::TimeAdded { micros: 2_000_000 }, + }, + Command::DeductPoints { + heat: heat(), + competitor: CompetitorRef("A".into()), + points: 1, + }, + // A heat-TAGGED insert names the Final heat directly. + Command::InsertLap { + adapter: AdapterId("vd".into()), + competitor: CompetitorRef("A".into()), + at: SourceTime::from_micros(2_000_000), + heat: Some(heat()), + }, + // The offset-addressed commands resolve the pass's OWNING heat (positional → q-1). + Command::VoidDetection { + target: LogRef(pass_offset), + }, + Command::AdjustLap { + target: LogRef(pass_offset), + at: SourceTime::from_micros(1_500_000), + }, + Command::SplitLap { + target: LogRef(pass_offset), + at: SourceTime::from_micros(500_000), + }, + Command::ThrowOutLap { + target: LogRef(pass_offset), + }, + ]; + + // On the Final heat: every command bounces with the exact message, appending nothing. + for cmd in &commands { + let (before, _) = state.read().unwrap(); + let ack = apply_command(&state, cmd.clone()); + assert!(!ack.ok, "{cmd:?} must be rejected on a Final heat"); + let err = ack.error.expect("a failed ack carries the error"); + assert_eq!(err.code, ErrorCode::BadRequest, "{cmd:?}"); + assert_eq!(err.message, final_lock_message(), "{cmd:?}"); + let (after, _) = state.read().unwrap(); + assert_eq!( + before.len(), + after.len(), + "{cmd:?} appended on a Final heat" + ); + } + + // Revert (Final → Unofficial) is the sanctioned re-open… + let ack = apply_command(&state, Command::Revert { heat: heat() }); + assert!(ack.ok, "Revert re-opens the result: {ack:?}"); + // …after which the very same commands are accepted. + for cmd in &commands { + let ack = apply_command(&state, cmd.clone()); + assert!(ack.ok, "{cmd:?} must be accepted after Revert: {ack:?}"); + } + } + + /// The target-addressed resolution end to end: voiding a pass that belongs to a Final heat + /// via its offset is rejected; the SAME offset is voidable after Revert. + #[test] + fn void_by_offset_is_locked_while_the_owning_heat_is_final() { + let (state, pass_offset) = final_state_with_pass(); + + let ack = apply_command( + &state, + Command::VoidDetection { + target: LogRef(pass_offset), + }, + ); + assert!(!ack.ok, "voiding a Final heat's pass must be rejected"); + assert_eq!(ack.error.unwrap().message, final_lock_message()); + + assert!(apply_command(&state, Command::Revert { heat: heat() }).ok); + let ack = apply_command( + &state, + Command::VoidDetection { + target: LogRef(pass_offset), + }, + ); + assert!(ack.ok, "the same offset voids after Revert: {ack:?}"); + } + + /// `ReverseRuling` is locked on a Final heat too — its owning heat resolves through the + /// ruling chain (the reversal targets a penalty whose TAG names the Final heat). + #[test] + fn reverse_ruling_is_locked_via_the_ruling_chain_owning_heat() { + // Bank a penalty while the heat is still Unofficial (legal), then finalize. + let state = scheduled_state(); + for cmd in [ + Command::Stage { heat: heat() }, + Command::Start { heat: heat() }, + Command::SkipCountdown { heat: heat() }, + Command::ForceEnd { heat: heat() }, + ] { + assert!(apply_command(&state, cmd).ok); + } + assert!( + apply_command( + &state, + Command::ApplyPenalty { + heat: heat(), + competitor: CompetitorRef("A".into()), + penalty: Penalty::TimeAdded { micros: 1_000_000 }, + }, + ) + .ok + ); + let (events, _) = state.read().unwrap(); + let penalty_offset = (events.len() - 1) as u64; + assert!(matches!(events.last(), Some(Event::PenaltyApplied { .. }))); + assert!(apply_command(&state, Command::Finalize { heat: heat() }).ok); + + // Reversing the penalty would change the OFFICIAL result — rejected. + let ack = apply_command( + &state, + Command::ReverseRuling { + target: LogRef(penalty_offset), + }, + ); + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().message, final_lock_message()); + + // Revert, then the reversal lands. + assert!(apply_command(&state, Command::Revert { heat: heat() }).ok); + let ack = apply_command( + &state, + Command::ReverseRuling { + target: LogRef(penalty_offset), + }, + ); + assert!(ack.ok, "reversal after Revert: {ack:?}"); + } + + /// An UNTAGGED (legacy) `InsertLap` attributes positionally, so the lock checks the + /// positionally-active heat at the log tail — Final rejects, post-Revert accepts, and an + /// empty log (no heat to protect) always accepts. + #[test] + fn untagged_insert_lap_checks_the_positionally_active_heat_at_the_tail() { + let insert = || Command::InsertLap { + adapter: AdapterId("vd".into()), + competitor: CompetitorRef("A".into()), + at: SourceTime::from_micros(3_000_000), + heat: None, + }; + + // The log tail sits inside q-1's span and q-1 is Final → the insertion would attribute + // to the official result: rejected. + let (state, _) = final_state_with_pass(); + let ack = apply_command(&state, insert()); + assert!(!ack.ok, "untagged insert on a Final tail must be rejected"); + assert_eq!(ack.error.unwrap().message, final_lock_message()); + + // After Revert the same insertion lands. + assert!(apply_command(&state, Command::Revert { heat: heat() }).ok); + assert!(apply_command(&state, insert()).ok); + + // With no heat in the log at all there is nothing official to protect — allowed. + let empty = AppState::new(InMemoryLog::default()); + assert!(apply_command(&empty, insert()).ok); + } + + /// Protests are EXEMPT from the lock: filing and resolving change no result, so both stay + /// legal on a Final heat (disputing an official result is what protests are for) — and the + /// heat remains Final throughout. + #[test] + fn file_and_resolve_protest_are_allowed_on_a_final_heat() { + use gridfpv_engine::heat::{HeatState, heat_state}; + use gridfpv_events::ProtestOutcome; + + let (state, _) = final_state_with_pass(); + + let ack = apply_command( + &state, + Command::FileProtest { + heat: heat(), + competitor: CompetitorRef("A".into()), + note: "protesting the official result".into(), + }, + ); + assert!(ack.ok, "FileProtest on a Final heat: {ack:?}"); + let (events, _) = state.read().unwrap(); + let filed_offset = (events.len() - 1) as u64; + assert!(matches!(events.last(), Some(Event::ProtestFiled { .. }))); + + // Resolving it (here: denied) needs no Revert either. + let ack = apply_command( + &state, + Command::ResolveProtest { + target: LogRef(filed_offset), + outcome: ProtestOutcome::Denied, + }, + ); + assert!(ack.ok, "ResolveProtest on a Final heat: {ack:?}"); + + // The result stayed official the whole time. + let (events, _) = state.read().unwrap(); + assert_eq!(heat_state(&events, &heat()), Some(HeatState::Final)); + } + + /// `heat_of_offset` resolves an offset's owning heat by tag, by ruling-chain recursion, and + /// positionally — mirroring `app::heat_window_offsets`' routing rules. + #[test] + fn heat_of_offset_resolves_tags_chains_and_positional_attribution() { + use gridfpv_events::ProtestOutcome; + + let h1 = HeatId("h-1".into()); + let h2 = HeatId("h-2".into()); + let schedule = |h: &HeatId| Event::HeatScheduled { + heat: h.clone(), + lineup: vec![], + class: None, + round: None, + frequencies: vec![], + label: None, + }; + let events = vec![ + pass("X", 500_000, 1), // 0: a pass before ANY heat — unattributable + schedule(&h1), // 1: h1 opens + pass("A", 1_000_000, 2), // 2: positional → h1 + Event::LapInserted { + // 3: UNTAGGED legacy insert — positional → h1 + adapter: AdapterId("vd".into()), + competitor: CompetitorRef("A".into()), + at: SourceTime::from_micros(1_500_000), + heat: None, + }, + schedule(&h2), // 4: h2 opens (closes h1's span) + pass("B", 2_000_000, 3), // 5: positional → h2 + Event::PenaltyApplied { + // 6: TAGGED for h1 while h2 is active — tag beats position + heat: h1.clone(), + competitor: CompetitorRef("A".into()), + penalty: Penalty::TimeAdded { micros: 1_000_000 }, + }, + Event::DetectionVoided { target: LogRef(2) }, // 7: chain → pass@2 → h1 + Event::ProtestFiled { + // 8: tagged h1 + heat: h1.clone(), + competitor: CompetitorRef("A".into()), + note: "contact".into(), + }, + Event::ProtestResolved { + // 9: chain → filed@8 → h1 + target: LogRef(8), + outcome: ProtestOutcome::Denied, + }, + Event::RulingReversed { target: LogRef(9) }, // 10: chain, two hops → h1 + Event::LapInserted { + // 11: TAGGED insert for h2 + adapter: AdapterId("vd".into()), + competitor: CompetitorRef("B".into()), + at: SourceTime::from_micros(2_500_000), + heat: Some(h2.clone()), + }, + Event::RulingReversed { target: LogRef(12) }, // 12: malformed SELF-target — bails + ]; + + assert_eq!(heat_of_offset(&events, LogRef(0)), None, "pre-heat pass"); + assert_eq!(heat_of_offset(&events, LogRef(2)), Some(h1.clone())); + assert_eq!( + heat_of_offset(&events, LogRef(3)), + Some(h1.clone()), + "untagged insert attributes positionally" + ); + assert_eq!(heat_of_offset(&events, LogRef(5)), Some(h2.clone())); + assert_eq!( + heat_of_offset(&events, LogRef(6)), + Some(h1.clone()), + "the tag wins over the active span" + ); + assert_eq!(heat_of_offset(&events, LogRef(7)), Some(h1.clone())); + assert_eq!( + heat_of_offset(&events, LogRef(10)), + Some(h1.clone()), + "ruling-chain recursion (reversal → resolution → filing)" + ); + assert_eq!(heat_of_offset(&events, LogRef(11)), Some(h2.clone())); + assert_eq!(heat_of_offset(&events, LogRef(99)), None, "out of range"); + assert_eq!( + heat_of_offset(&events, LogRef(12)), + None, + "a malformed self-target bails instead of looping" + ); + } + + /// `Register` acks ok and appends the `CompetitorRegistered` binding (#60). + #[test] + fn register_appends_competitor_registered_and_acks_ok() { + use gridfpv_events::PilotId; + let state = scheduled_state(); + let ack = apply_command( + &state, + Command::Register { + adapter: AdapterId("rh".into()), + competitor: CompetitorRef("node-2".into()), + pilot: PilotId("acroace".into()), + }, + ); + assert!(ack.ok, "got {ack:?}"); + assert!(ack.error.is_none()); + + let (events, _) = state.read().unwrap(); + assert!(events.iter().any(|e| matches!( + e, + Event::CompetitorRegistered { adapter, competitor, pilot } + if *adapter == AdapterId("rh".into()) + && *competitor == CompetitorRef("node-2".into()) + && *pilot == PilotId("acroace".into()) + ))); + } + + /// `FillRound` (race redesign Slice 3a), through the event-aware control path, builds the + /// round's generator from the class membership and appends a tagged `HeatScheduled`. + #[test] + fn fill_round_schedules_a_tagged_heat_from_membership() { + use crate::classes::CreateClassRequest; + use crate::events::{ + ChannelMode, CreateEventRequest, MemberSlot, NewRoundReq, SeedingRule, + }; + use crate::pilots::CreatePilotRequest; + use crate::scope::EventId; + use gridfpv_engine::scoring::WinCondition; + use gridfpv_events::{ClassId, RoundId}; + use std::collections::BTreeMap; + + let registry = EventRegistry::new(None).unwrap(); + // Seed a directory class + two pilots, an event that selects the class, the class + // membership, and a single-round timed_qual round. + let class = registry + .classes() + .create(&CreateClassRequest { + name: "Open".into(), + source: Default::default(), + reference: None, + description: None, + }) + .unwrap() + .id; + let mut pilots = Vec::new(); + for cs in ["alpha", "bravo"] { + pilots.push( + registry + .pilots() + .create(&CreatePilotRequest { + callsign: cs.into(), + ..Default::default() + }) + .unwrap() + .id, + ); + } + let event = registry + .create(&CreateEventRequest { + name: "E".into(), + date: None, + location: None, + description: None, + organizer: None, + }) + .unwrap() + .id; + registry.set_classes(&event, vec![class.clone()]).unwrap(); + registry + .set_class_membership( + &event, + class.clone(), + pilots.iter().cloned().map(MemberSlot::new).collect(), + ) + .unwrap(); + let round = registry + .add_round( + &event, + NewRoundReq { + label: "Qual".into(), + classes: vec![class.clone()], + format: "timed_qual".into(), + params: BTreeMap::from([("rounds".into(), "1".into())]), + win_condition: Some(WinCondition::BestLap), + seeding: SeedingRule::FromRoster, + // Best-lap only ranks, so a scored round needs a race time to end (validation). + time_limit_secs: Some(60), + // Per-heat: this test asserts the whole-field single heat (the bracket path). + channel_mode: Some(ChannelMode::PerHeat), + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }, + ) + .unwrap(); + + let state = registry.resolve(&event).unwrap(); + let event_id = EventId(event.0.clone()); + let ack = apply_command_in_event( + ®istry, + &event_id, + &state, + Command::FillRound { + round: round.id.clone(), + mode: FillMode::Next, + }, + ); + assert!(ack.ok, "FillRound rejected: {ack:?}"); + + // A HeatScheduled tagged with the round + the single class, lineup = the membership. + let (events, _) = state.read().unwrap(); + let scheduled = events + .iter() + .find_map(|e| match e { + Event::HeatScheduled { + lineup, + class: Some(c), + round: Some(r), + .. + } => Some((lineup.clone(), c.clone(), r.clone())), + _ => None, + }) + .expect("FillRound appended a tagged HeatScheduled"); + assert_eq!(scheduled.1, ClassId(class.0.clone())); + assert_eq!(scheduled.2, RoundId(round.id.0.clone())); + assert_eq!( + scheduled.0, + pilots + .iter() + .map(|p| CompetitorRef(p.0.clone())) + .collect::>() + ); + } + + /// `FillRound` on a round that does not exist is an `UnknownScope` rejection (no append). + #[test] + fn fill_round_unknown_round_is_rejected() { + use crate::scope::EventId; + use gridfpv_events::RoundId; + + let registry = EventRegistry::new(None).unwrap(); + let event = EventId(crate::events::PRACTICE_EVENT_ID.into()); + let state = registry.resolve(&event).unwrap(); + let ack = apply_command_in_event( + ®istry, + &event, + &state, + Command::FillRound { + round: RoundId("nope".into()), + mode: FillMode::Next, + }, + ); + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().code, ErrorCode::UnknownScope); + let (events, _) = state.read().unwrap(); + assert!(events.is_empty(), "a rejected FillRound appends nothing"); + } + + /// Build an event selecting one timer (created from `req`) over a class with `pilots`, plus a + /// single-round timed_qual round. Returns the registry, the event id, and the round id. + #[cfg(test)] + fn event_with_timer_and_round( + timer_req: crate::timers::CreateTimerRequest, + pilots: &[&str], + ) -> (EventRegistry, EventId, gridfpv_events::RoundId) { + use crate::classes::CreateClassRequest; + use crate::events::{ + ChannelMode, CreateEventRequest, MemberSlot, NewRoundReq, SeedingRule, + }; + use crate::pilots::CreatePilotRequest; + use gridfpv_engine::scoring::WinCondition; + use std::collections::BTreeMap; + + let registry = EventRegistry::new(None).unwrap(); + let timer = registry.timers().create(&timer_req).unwrap(); + let class = registry + .classes() + .create(&CreateClassRequest { + name: "Open".into(), + source: Default::default(), + reference: None, + description: None, + }) + .unwrap() + .id; + let pilot_ids: Vec<_> = pilots + .iter() + .map(|cs| { + registry + .pilots() + .create(&CreatePilotRequest { + callsign: (*cs).into(), + ..Default::default() + }) + .unwrap() + .id + }) + .collect(); + let event = registry + .create(&CreateEventRequest { + name: "E".into(), + date: None, + location: None, + description: None, + organizer: None, + }) + .unwrap() + .id; + registry.set_classes(&event, vec![class.clone()]).unwrap(); + registry + .set_class_membership( + &event, + class.clone(), + pilot_ids.into_iter().map(MemberSlot::new).collect(), + ) + .unwrap(); + registry.set_timers(&event, vec![timer.id]).unwrap(); + let round = registry + .add_round( + &event, + NewRoundReq { + label: "Qual".into(), + classes: vec![class], + format: "timed_qual".into(), + params: BTreeMap::from([("rounds".into(), "1".into())]), + win_condition: Some(WinCondition::BestLap), + seeding: SeedingRule::FromRoster, + // Best-lap only ranks, so a scored round needs a race time to end (validation). + time_limit_secs: Some(60), + // Per-heat: this test asserts first-fit channel assignment from the timer pool. + channel_mode: Some(ChannelMode::PerHeat), + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }, + ) + .unwrap(); + (registry, EventId(event.0.clone()), round.id) + } + + /// `FillRound` assigns channels from the event's selected timer onto the heat — the lineup gets + /// the **IMD-best** Raceband subset for its simultaneous size (#209 auto-pick), laid onto the + /// seeds in order (race redesign Slice 4a + #209). + #[test] + fn fill_round_assigns_frequencies_from_the_selected_timer() { + use crate::timers::{ChannelCapability, CreateTimerRequest, TimerKind}; + let timer_req = CreateTimerRequest { + name: "8-node".into(), + kind: TimerKind::Mock { laps: 1, lap_ms: 1 }, + channel_capability: Some(ChannelCapability::Flexible), + node_count: Some(8), + available_channels: Some(crate::channels::RACEBAND_MHZ.to_vec()), + }; + let (registry, event_id, round) = + event_with_timer_and_round(timer_req, &["alpha", "bravo"]); + let state = registry.resolve(&event_id).unwrap(); + let ack = apply_command_in_event( + ®istry, + &event_id, + &state, + Command::FillRound { + round: round.clone(), + mode: FillMode::Next, + }, + ); + assert!(ack.ok, "FillRound rejected: {ack:?}"); + + let (events, _) = state.read().unwrap(); + let freqs = events + .iter() + .find_map(|e| match e { + Event::HeatScheduled { frequencies, .. } if !frequencies.is_empty() => { + Some(frequencies.clone()) + } + _ => None, + }) + .expect("the scheduled heat carries an assigned frequency set"); + // #209: the two seeds get the IMD-cleanest 2-channel Raceband subset — the widest-spread + // pair R1 (5658) and R8 (5917) — in seed order (lowest channel → top seed), not the naive + // first-fit R1, R2. + assert_eq!(freqs.len(), 2); + assert_eq!(freqs[0].1, 5658); + assert_eq!(freqs[1].1, 5917); + } + + /// `FillRound` rejects an oversized lineup with a typed `BadRequest` (the heat-size cap) and + /// appends nothing (race redesign Slice 4a). + #[test] + fn fill_round_rejects_a_lineup_over_the_node_cap() { + use crate::timers::{ChannelCapability, CreateTimerRequest, TimerKind}; + // A 2-node timer, but the round fields four pilots — the heat exceeds the cap. + let timer_req = CreateTimerRequest { + name: "2-node".into(), + kind: TimerKind::Mock { laps: 1, lap_ms: 1 }, + channel_capability: Some(ChannelCapability::Flexible), + node_count: Some(2), + available_channels: Some(crate::channels::RACEBAND_MHZ.to_vec()), + }; + let (registry, event_id, round) = + event_with_timer_and_round(timer_req, &["a", "b", "c", "d"]); + let state = registry.resolve(&event_id).unwrap(); + let before = state.read().unwrap().0.len(); + let ack = apply_command_in_event( + ®istry, + &event_id, + &state, + Command::FillRound { + round, + mode: FillMode::Next, + }, + ); + assert!(!ack.ok, "an oversized heat must be rejected"); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + let after = state.read().unwrap().0.len(); + assert_eq!(before, after, "a rejected FillRound appends nothing"); + } + + // --- #335: ScheduleHeat tag + membership validation (the event-aware path) --------------- + + /// An 8-node event over a class of `pilots` with a single timed_qual round — the tagged + /// ScheduleHeat fixture. Returns the registry, event id, round id, class id, and the member + /// pilot ids (in membership order). + #[cfg(test)] + fn tagged_schedule_fixture( + pilots: &[&str], + ) -> ( + EventRegistry, + EventId, + gridfpv_events::RoundId, + gridfpv_events::ClassId, + Vec, + ) { + use crate::timers::{ChannelCapability, CreateTimerRequest, TimerKind}; + let timer_req = CreateTimerRequest { + name: "8-node".into(), + kind: TimerKind::Mock { laps: 1, lap_ms: 1 }, + channel_capability: Some(ChannelCapability::Flexible), + node_count: Some(8), + available_channels: Some(crate::channels::RACEBAND_MHZ.to_vec()), + }; + let (registry, event_id, round) = event_with_timer_and_round(timer_req, pilots); + let meta = registry.meta_of(&event_id).unwrap(); + let class = meta.classes[0].clone(); + let members = meta.classes_membership[0] + .pilots + .iter() + .map(|s| s.pilot.clone()) + .collect(); + (registry, event_id, round, class, members) + } + + /// The tagged ScheduleHeat under test, with the fixture's default shape (no explicit + /// frequencies, no label) — each test varies the tag/lineup it cares about. + #[cfg(test)] + fn schedule_tagged( + registry: &EventRegistry, + event_id: &EventId, + state: &AppState, + heat: &str, + lineup: Vec, + class: Option, + round: Option, + ) -> CommandAck { + apply_command_in_event( + registry, + event_id, + state, + Command::ScheduleHeat { + heat: HeatId(heat.into()), + lineup, + class, + round, + frequencies: vec![], + label: None, + }, + ) + } + + /// A `round` tag must name one of the event's rounds (#335) — a dangling tag would create a + /// heat no round view lists and no generator accounts for. + #[test] + fn schedule_heat_rejects_an_unknown_round_tag() { + use gridfpv_events::RoundId; + let (registry, event_id, _round, _class, members) = tagged_schedule_fixture(&["a", "b"]); + let state = registry.resolve(&event_id).unwrap(); + let lineup = vec![CompetitorRef(members[0].0.clone())]; + let ack = schedule_tagged( + ®istry, + &event_id, + &state, + "x-1", + lineup, + None, + Some(RoundId("nope".into())), + ); + assert!(!ack.ok, "a dangling round tag must be rejected"); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + let (events, _) = state.read().unwrap(); + assert!(events.is_empty(), "a rejected ScheduleHeat appends nothing"); + } + + /// A `class` tag must be one the event **selects** (#335) — mirroring the membership PUT's + /// class-selection guard (#330). + #[test] + fn schedule_heat_rejects_an_unselected_class_tag() { + use gridfpv_events::ClassId; + let (registry, event_id, _round, _class, members) = tagged_schedule_fixture(&["a", "b"]); + let state = registry.resolve(&event_id).unwrap(); + let lineup = vec![CompetitorRef(members[0].0.clone())]; + let ack = schedule_tagged( + ®istry, + &event_id, + &state, + "x-1", + lineup, + Some(ClassId("nope".into())), + None, + ); + assert!(!ack.ok, "an unselected class tag must be rejected"); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + } + + /// With BOTH tags, the class must be **eligible** for the round (in its `classes`) — a + /// selected-but-ineligible class would file the heat under a round that never runs it (#335). + #[test] + fn schedule_heat_rejects_a_class_not_eligible_for_the_round() { + use crate::classes::CreateClassRequest; + let (registry, event_id, round, class, members) = tagged_schedule_fixture(&["a", "b"]); + // A second directory class, selected by the event but NOT eligible for the round. + let spec = registry + .classes() + .create(&CreateClassRequest { + name: "Spec".into(), + source: Default::default(), + reference: None, + description: None, + }) + .unwrap() + .id; + registry + .set_classes(&event_id, vec![class, spec.clone()]) + .unwrap(); + let state = registry.resolve(&event_id).unwrap(); + let lineup = vec![CompetitorRef(members[0].0.clone())]; + let ack = schedule_tagged( + ®istry, + &event_id, + &state, + "x-1", + lineup, + Some(spec), + Some(round), + ); + assert!(!ack.ok, "a round-ineligible class tag must be rejected"); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + } + + /// On a tagged heat, a lineup ref naming a **directory pilot** must be an eligible member — + /// a real pilot outside the round's classes' membership must not be seated (#335, closing + /// the manual bypass of FillRound's membership-resolved field). + #[test] + fn schedule_heat_rejects_a_non_member_pilot_on_a_tagged_heat() { + use crate::pilots::CreatePilotRequest; + let (registry, event_id, round, class, members) = tagged_schedule_fixture(&["a", "b"]); + // A directory pilot who is NOT in the round's class membership. + let outsider = registry + .pilots() + .create(&CreatePilotRequest { + callsign: "outsider".into(), + ..Default::default() + }) + .unwrap() + .id; + let state = registry.resolve(&event_id).unwrap(); + let lineup = vec![ + CompetitorRef(members[0].0.clone()), + CompetitorRef(outsider.0.clone()), + ]; + let ack = schedule_tagged( + ®istry, + &event_id, + &state, + "x-1", + lineup, + Some(class), + Some(round), + ); + assert!(!ack.ok, "a non-member directory pilot must be rejected"); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + let (events, _) = state.read().unwrap(); + assert!(events.is_empty(), "a rejected ScheduleHeat appends nothing"); + } + + /// The happy paths stay open (#335): eligible members schedule tagged; **non-pilot refs** + /// (`node-{i}` timer seats — the open-practice channel lineup — and sim free-text names) + /// pass the membership check; and an **untagged** heat validates none of it. + #[test] + fn schedule_heat_accepts_members_node_seats_and_untagged_lineups() { + use crate::pilots::CreatePilotRequest; + let (registry, event_id, round, class, members) = tagged_schedule_fixture(&["a", "b"]); + let state = registry.resolve(&event_id).unwrap(); + + // Eligible members, tagged with their round + class — the console's build path. + let lineup: Vec = + members.iter().map(|p| CompetitorRef(p.0.clone())).collect(); + let ack = schedule_tagged( + ®istry, + &event_id, + &state, + "x-1", + lineup, + Some(class), + Some(round.clone()), + ); + assert!(ack.ok, "eligible members schedule tagged: {ack:?}"); + + // A node-seat ref on a tagged heat is NOT membership-checked (the practice-style path). + let ack = schedule_tagged( + ®istry, + &event_id, + &state, + "x-2", + vec![CompetitorRef("node-0".into())], + None, + Some(round), + ); + assert!(ack.ok, "node-seat refs pass the membership check: {ack:?}"); + + // An untagged heat skips tag validation entirely — even for a known non-member pilot + // (the free-text / ad-hoc path stays as permissive as before). + let outsider = registry + .pilots() + .create(&CreatePilotRequest { + callsign: "outsider".into(), + ..Default::default() + }) + .unwrap() + .id; + let ack = schedule_tagged( + ®istry, + &event_id, + &state, + "x-3", + vec![CompetitorRef(outsider.0.clone())], + None, + None, + ); + assert!( + ack.ok, + "an untagged heat is not membership-checked: {ack:?}" + ); + } + + /// Build an event with an 8-node Raceband timer over a class of `pilots`, plus a single + /// **head_to_head** round (`group_size=heat_size`) — a *deterministic* format whose one + /// generator step emits the whole round's heats (the field split into groups). Returns the + /// registry, event id, and round id. Used by the fill-all (#216) tests. (Was `round_robin` + /// before that format was carved out for the primitives-first release; head_to_head has the same + /// "one step emits the whole round" shape the fill-all tests need.) + #[cfg(test)] + fn round_robin_event( + pilots: &[&str], + heat_size: u32, + ) -> (EventRegistry, EventId, gridfpv_events::RoundId) { + use crate::classes::CreateClassRequest; + use crate::events::{ + ChannelMode, CreateEventRequest, MemberSlot, NewRoundReq, SeedingRule, + }; + use crate::pilots::CreatePilotRequest; + use crate::timers::{ChannelCapability, CreateTimerRequest, TimerKind}; + use gridfpv_engine::scoring::WinCondition; + use std::collections::BTreeMap; + + let registry = EventRegistry::new(None).unwrap(); + let timer = registry + .timers() + .create(&CreateTimerRequest { + name: "8-node".into(), + kind: TimerKind::Mock { laps: 1, lap_ms: 1 }, + channel_capability: Some(ChannelCapability::Flexible), + node_count: Some(8), + available_channels: Some(crate::channels::RACEBAND_MHZ.to_vec()), + }) + .unwrap(); + let class = registry + .classes() + .create(&CreateClassRequest { + name: "Open".into(), + source: Default::default(), + reference: None, + description: None, + }) + .unwrap() + .id; + let pilot_ids: Vec<_> = pilots + .iter() + .map(|cs| { + registry + .pilots() + .create(&CreatePilotRequest { + callsign: (*cs).into(), + ..Default::default() + }) + .unwrap() + .id + }) + .collect(); + let event = registry + .create(&CreateEventRequest { + name: "E".into(), + date: None, + location: None, + description: None, + organizer: None, + }) + .unwrap() + .id; + registry.set_classes(&event, vec![class.clone()]).unwrap(); + registry + .set_class_membership( + &event, + class.clone(), + pilot_ids.into_iter().map(MemberSlot::new).collect(), + ) + .unwrap(); + registry.set_timers(&event, vec![timer.id]).unwrap(); + let round = registry + .add_round( + &event, + NewRoundReq { + label: "Round Robin".into(), + classes: vec![class], + format: "head_to_head".into(), + params: BTreeMap::from([("group_size".into(), heat_size.to_string())]), + win_condition: Some(WinCondition::BestLap), + seeding: SeedingRule::FromRoster, + // Best-lap only ranks, so a scored round needs a race time to end (validation). + time_limit_secs: Some(60), + channel_mode: Some(ChannelMode::PerHeat), + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }, + ) + .unwrap(); + (registry, EventId(event.0.clone()), round.id) + } + + /// Count the heats tagged with `round` currently in the log. + #[cfg(test)] + fn heats_in_round(state: &AppState, round: &gridfpv_events::RoundId) -> usize { + let (events, _) = state.read().unwrap(); + events + .iter() + .filter(|e| matches!(e, Event::HeatScheduled { round: Some(r), .. } if r == round)) + .count() + } + + /// `FillRound { mode: All }` (#216) on a deterministic format fills the **whole** round in one + /// command: a round_robin (`rounds=1`, `heat_size=2`) over 4 pilots partitions the field into + /// **2 heats**, and one fill-all command schedules both — where the single-step `Next` would + /// schedule only the first. + #[test] + fn fill_round_all_fills_the_whole_deterministic_round() { + let (registry, event_id, round) = + round_robin_event(&["alpha", "bravo", "charlie", "delta"], 2); + let state = registry.resolve(&event_id).unwrap(); + + let ack = apply_command_in_event( + ®istry, + &event_id, + &state, + Command::FillRound { + round: round.clone(), + mode: FillMode::All, + }, + ); + assert!(ack.ok, "FillRound(All) rejected: {ack:?}"); + // 4 pilots at heat_size 2 → 2 heats, both scheduled by the one fill-all command. + assert_eq!( + heats_in_round(&state, &round), + 2, + "fill-all schedules the whole round's heats in one command" + ); + } + + /// Determinism + idempotency (#216): a second `FillRound { mode: All }` on a round already + /// filled to its terminal state appends **nothing more** (the generator is deterministic, and + /// every plan it wants now is already scheduled), and the heats are unchanged. + #[test] + fn fill_round_all_is_idempotent_on_a_filled_round() { + let (registry, event_id, round) = + round_robin_event(&["alpha", "bravo", "charlie", "delta"], 2); + let state = registry.resolve(&event_id).unwrap(); + let all = || { + apply_command_in_event( + ®istry, + &event_id, + &state, + Command::FillRound { + round: round.clone(), + mode: FillMode::All, + }, + ) + }; + + assert!(all().ok); + let after_first: Vec<_> = { + let (events, _) = state.read().unwrap(); + events.to_vec() + }; + assert_eq!(heats_in_round(&state, &round), 2); + + // Re-run: still ok, but nothing new — the log is byte-identical. + assert!(all().ok, "re-filling a complete round is a typed ok"); + let after_second: Vec<_> = { + let (events, _) = state.read().unwrap(); + events.to_vec() + }; + assert_eq!( + heats_in_round(&state, &round), + 2, + "a re-run of fill-all adds no heats" + ); + assert_eq!( + after_first, after_second, + "fill-all is idempotent: the second run appends nothing" + ); + } + + /// P1-8: a `FillMode::All` on an **open-ended `Static` round** (`rounds=0`) is rejected up front + /// — its generator never reports Complete, so without the guard a fill-all would schedule up to + /// the 1000-heat cap while acking ok. Here it acks **failed** (BadRequest) and schedules nothing. + #[test] + fn fill_round_all_rejects_an_open_ended_static_round() { + use crate::classes::CreateClassRequest; + use crate::events::{CreateEventRequest, MemberSlot, NewRoundReq, SeedingRule}; + use crate::pilots::CreatePilotRequest; + use crate::timers::{ChannelCapability, CreateTimerRequest, TimerKind}; + use gridfpv_engine::scoring::WinCondition; + use std::collections::BTreeMap; + + let registry = EventRegistry::new(None).unwrap(); + let timer = registry + .timers() + .create(&CreateTimerRequest { + name: "8-node".into(), + kind: TimerKind::Mock { laps: 1, lap_ms: 1 }, + channel_capability: Some(ChannelCapability::Flexible), + node_count: Some(8), + available_channels: Some(crate::channels::RACEBAND_MHZ.to_vec()), + }) + .unwrap(); + let class = registry + .classes() + .create(&CreateClassRequest { + name: "Open".into(), + source: Default::default(), + reference: None, + description: None, + }) + .unwrap() + .id; + let pilots: Vec<_> = ["alpha", "bravo"] + .iter() + .map(|cs| { + registry + .pilots() + .create(&CreatePilotRequest { + callsign: (*cs).into(), + ..Default::default() + }) + .unwrap() + .id + }) + .collect(); + let event = registry + .create(&CreateEventRequest { + name: "E".into(), + date: None, + location: None, + description: None, + organizer: None, + }) + .unwrap() + .id; + registry.set_classes(&event, vec![class.clone()]).unwrap(); + let channels = [5658u16, 5695u16]; + registry + .set_class_membership( + &event, + class.clone(), + pilots + .into_iter() + .zip(channels) + .map(|(pilot, ch)| MemberSlot { + pilot, + channel: Some(ch), + }) + .collect(), + ) + .unwrap(); + registry.set_timers(&event, vec![timer.id]).unwrap(); + // A `timed_qual` round defaults to `ChannelMode::Static`; `rounds=0` makes it open-ended. + let round = registry + .add_round( + &event, + NewRoundReq { + label: "Time Trials".into(), + classes: vec![class], + format: "timed_qual".into(), + params: BTreeMap::from([("rounds".into(), "0".into())]), + win_condition: Some(WinCondition::BestLap), + seeding: SeedingRule::FromRoster, + time_limit_secs: Some(60), + channel_mode: None, + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }, + ) + .unwrap(); + let event_id = EventId(event.0.clone()); + let state = registry.resolve(&event_id).unwrap(); + + let ack = apply_command_in_event( + ®istry, + &event_id, + &state, + Command::FillRound { + round: round.id.clone(), + mode: FillMode::All, + }, + ); + assert!( + !ack.ok, + "fill-all on an open-ended static round must be rejected" + ); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + assert_eq!( + heats_in_round(&state, &round.id), + 0, + "the rejected fill-all scheduled NO heats (not 1000)" + ); + } + + /// Open practice still **single-steps** (#216): its one channel heat is one draw, so a `Next` + /// fill schedules exactly that heat — fill-all is not used for the (dynamic) practice format. + /// (Open practice rounds also auto-create their heat on creation; here we drive `Next` against + /// a fresh round to assert the single-step path appends exactly one heat.) + #[test] + fn open_practice_fills_a_single_heat_per_step() { + use crate::events::{CreateEventRequest, NewRoundReq, SeedingRule}; + use std::collections::BTreeMap; + + let registry = EventRegistry::new(None).unwrap(); + let event = registry + .create(&CreateEventRequest { + name: "Practice".into(), + date: None, + location: None, + description: None, + organizer: None, + }) + .unwrap() + .id; + // An open-practice round: channel-seated (`AllChannels` seeding), no class membership. + // Its single channel heat is one draw — the (dynamic) format the RD single-steps, never + // fill-all. + let round = registry + .add_round( + &event, + NewRoundReq { + label: "Open Practice".into(), + classes: vec![], + format: "open_practice".into(), + params: BTreeMap::new(), + win_condition: None, + seeding: SeedingRule::AllChannels { + channels: vec![0, 1, 2], + }, + time_limit_secs: None, + channel_mode: None, + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }, + ) + .unwrap(); + let event_id = EventId(event.0.clone()); + let state = registry.resolve(&event_id).unwrap(); + let next = || { + apply_command_in_event( + ®istry, + &event_id, + &state, + Command::FillRound { + round: round.id.clone(), + mode: FillMode::Next, + }, + ) + }; + + // The registry `add_round` does not auto-fill (that is the HTTP handler's job), so the + // round starts empty. The single-step Next schedules exactly the one channel heat. + assert_eq!(heats_in_round(&state, &round.id), 0); + assert!(next().ok, "open-practice Next rejected"); + assert_eq!( + heats_in_round(&state, &round.id), + 1, + "a single-step fill schedules open practice's one channel heat" + ); + + // It does not multiply: a second Next is idempotent (the heat is already scheduled). + assert!(next().ok); + assert_eq!( + heats_in_round(&state, &round.id), + 1, + "open practice stays at its single heat — single-stepping it never adds more" + ); + } + + // --- Advance loads the next heat (fix: Advance was a no-op on Live control) --------------- + + /// The current heat the live projection is focused on, for the Advance tests to assert against. + #[cfg(test)] + fn current_heat_of(state: &AppState) -> Option { + let (events, _) = state.read().unwrap(); + crate::live_state::live_state(&events).current_heat + } + + /// Drive `heat` from `Scheduled` all the way to `Final` through the control path (the runtime + /// override transitions stand in for the auto-clock so a test can finalize without timers). + #[cfg(test)] + fn drive_heat_to_final( + registry: &EventRegistry, + event_id: &EventId, + state: &AppState, + heat: &HeatId, + ) { + for cmd in [ + Command::Stage { heat: heat.clone() }, + Command::Start { heat: heat.clone() }, + Command::SkipCountdown { heat: heat.clone() }, + Command::ForceEnd { heat: heat.clone() }, + Command::Finalize { heat: heat.clone() }, + ] { + let ack = apply_command_in_event(registry, event_id, state, cmd); + assert!(ack.ok, "driving {heat:?} to Final: {ack:?}"); + } + assert_eq!( + heat_state(state, heat), + Some(gridfpv_engine::heat::HeatState::Final), + "{heat:?} should be Final before Advance" + ); + } + + /// Fold a single heat's current state through the control path (test helper). + #[cfg(test)] + fn heat_state(state: &AppState, heat: &HeatId) -> Option { + let (events, _) = state.read().unwrap(); + gridfpv_engine::heat::heat_state(&events, heat) + } + + /// The heat ids tagged with `round`, in first-scheduled order (test helper). + #[cfg(test)] + fn heat_ids_in_round(state: &AppState, round: &gridfpv_events::RoundId) -> Vec { + let (events, _) = state.read().unwrap(); + let mut out = Vec::new(); + for e in &events { + if let Event::HeatScheduled { + heat, + round: Some(r), + .. + } = e + { + if r == round && !out.contains(heat) { + out.push(heat.clone()); + } + } + } + out + } + + /// The core fix: with the round's heats already scheduled, finalizing heat 1 and then + /// `Advance`-ing it **loads heat 2** into Live control (the on-deck case). Before the fix + /// Advance only recorded the `Advanced` transition and `current_heat` stayed on heat 1. + #[test] + fn advance_loads_the_next_already_scheduled_heat() { + let (registry, event_id, round) = + round_robin_event(&["alpha", "bravo", "charlie", "delta"], 2); + let state = registry.resolve(&event_id).unwrap(); + + // Fill the whole round up front: 4 pilots at heat_size 2 → 2 scheduled heats. + assert!( + apply_command_in_event( + ®istry, + &event_id, + &state, + Command::FillRound { + round: round.clone(), + mode: FillMode::All, + }, + ) + .ok + ); + let heats = heat_ids_in_round(&state, &round); + assert_eq!(heats.len(), 2, "the round has two scheduled heats"); + let (heat1, heat2) = (heats[0].clone(), heats[1].clone()); + + // Heat 1 is the current heat (first scheduled); drive it to Final, then Advance. + drive_heat_to_final(®istry, &event_id, &state, &heat1); + let ack = apply_command_in_event( + ®istry, + &event_id, + &state, + Command::Advance { + heat: heat1.clone(), + }, + ); + assert!(ack.ok, "Advance rejected: {ack:?}"); + + // Live control now follows to heat 2 (Scheduled, ready to Stage); heat 1 is Advanced/Final. + assert_eq!( + current_heat_of(&state), + Some(heat2.clone()), + "Advance loaded the next heat" + ); + assert_eq!( + heat_state(&state, &heat2), + Some(gridfpv_engine::heat::HeatState::Scheduled), + "the loaded heat is ready to Stage" + ); + assert_eq!( + heat_state(&state, &heat1), + Some(gridfpv_engine::heat::HeatState::Final), + "the advanced heat stays Final (terminal)" + ); + } + + /// The generate-if-needed case: only heat 1 is pre-scheduled. Advancing it draws the round's + /// next heat from the generator and selects it — no manual fill in between. + #[test] + fn advance_generates_and_loads_the_next_heat_when_none_is_on_deck() { + let (registry, event_id, round) = + round_robin_event(&["alpha", "bravo", "charlie", "delta"], 2); + let state = registry.resolve(&event_id).unwrap(); + + // Single-step fill: only heat 1 exists; heat 2 is *not* yet scheduled. + assert!( + apply_command_in_event( + ®istry, + &event_id, + &state, + Command::FillRound { + round: round.clone(), + mode: FillMode::Next, + }, + ) + .ok + ); + assert_eq!( + heat_ids_in_round(&state, &round).len(), + 1, + "only heat 1 yet" + ); + let heat1 = heat_ids_in_round(&state, &round)[0].clone(); + + drive_heat_to_final(®istry, &event_id, &state, &heat1); + let ack = apply_command_in_event( + ®istry, + &event_id, + &state, + Command::Advance { + heat: heat1.clone(), + }, + ); + assert!(ack.ok, "Advance rejected: {ack:?}"); + + // Advance generated heat 2 and loaded it. + let heats = heat_ids_in_round(&state, &round); + assert_eq!( + heats.len(), + 2, + "Advance drew the next heat from the generator" + ); + let heat2 = heats[1].clone(); + assert_eq!( + current_heat_of(&state), + Some(heat2.clone()), + "Advance loaded the generated heat" + ); + assert_eq!( + heat_state(&state, &heat2), + Some(gridfpv_engine::heat::HeatState::Scheduled) + ); + } + + /// The round-complete case: advancing the *last* heat of a finished round leaves it Advanced + /// (a clean terminal) — no next heat, no crash, and Live control stays on the advanced heat. + #[test] + fn advance_on_the_last_heat_of_a_complete_round_stays_advanced() { + let (registry, event_id, round) = + round_robin_event(&["alpha", "bravo", "charlie", "delta"], 2); + let state = registry.resolve(&event_id).unwrap(); + + // Fill the whole round, then drive BOTH heats to Final so the round is genuinely complete. + assert!( + apply_command_in_event( + ®istry, + &event_id, + &state, + Command::FillRound { + round: round.clone(), + mode: FillMode::All, + }, + ) + .ok + ); + let heats = heat_ids_in_round(&state, &round); + assert_eq!(heats.len(), 2); + let (heat1, heat2) = (heats[0].clone(), heats[1].clone()); + + // Advance heat 1 to load heat 2, then finalize heat 2 and Advance it: nothing is left. + drive_heat_to_final(®istry, &event_id, &state, &heat1); + assert!( + apply_command_in_event( + ®istry, + &event_id, + &state, + Command::Advance { + heat: heat1.clone() + }, + ) + .ok + ); + drive_heat_to_final(®istry, &event_id, &state, &heat2); + let ack = apply_command_in_event( + ®istry, + &event_id, + &state, + Command::Advance { + heat: heat2.clone(), + }, + ); + assert!( + ack.ok, + "Advance on the last heat is still a typed ok: {ack:?}" + ); + + // No new heat was scheduled; heat 2 stays Final/Advanced and remains the current heat. + assert_eq!( + heat_ids_in_round(&state, &round).len(), + 2, + "a complete round draws no further heats" + ); + assert_eq!( + heat_state(&state, &heat2), + Some(gridfpv_engine::heat::HeatState::Final), + "the last advanced heat stays Final" + ); + assert_eq!( + current_heat_of(&state), + Some(heat2), + "with nothing to advance to, Live control stays on the advanced heat" + ); + } + + /// `Advance` is replay-deterministic: the log it produces (transition + generated heat + + /// selection) re-folds to the same live state every time, so a recorded session replays + /// identically. (The fixture ids are random per build, so we assert on the *replayed log*, not + /// on two independently-seeded fixtures.) + #[test] + fn advance_is_deterministic_on_replay() { + let (registry, event_id, round) = + round_robin_event(&["alpha", "bravo", "charlie", "delta"], 2); + let state = registry.resolve(&event_id).unwrap(); + apply_command_in_event( + ®istry, + &event_id, + &state, + Command::FillRound { + round: round.clone(), + mode: FillMode::Next, + }, + ); + let heat1 = heat_ids_in_round(&state, &round)[0].clone(); + drive_heat_to_final(®istry, &event_id, &state, &heat1); + apply_command_in_event( + ®istry, + &event_id, + &state, + Command::Advance { heat: heat1 }, + ); + + // Re-folding the same recorded log twice yields the same live state (same current heat). + let (events, _) = state.read().unwrap(); + assert_eq!( + crate::live_state::live_state(&events), + crate::live_state::live_state(&events), + "Advance's log re-folds deterministically", + ); + // And the generated next heat is the one loaded. + let heat2 = heat_ids_in_round(&state, &round)[1].clone(); + assert_eq!( + crate::live_state::live_state(&events).current_heat, + Some(heat2), + ); + } + + /// `Advance` on a heat that is not `Final` (here: never finalized) is rejected verbatim by the + /// engine's legality and appends nothing — the next-heat load only runs after a legal Advance. + #[test] + fn advance_on_a_non_final_heat_is_rejected_and_loads_nothing() { + let (registry, event_id, round) = + round_robin_event(&["alpha", "bravo", "charlie", "delta"], 2); + let state = registry.resolve(&event_id).unwrap(); + apply_command_in_event( + ®istry, + &event_id, + &state, + Command::FillRound { + round: round.clone(), + mode: FillMode::All, + }, + ); + let heats = heat_ids_in_round(&state, &round); + let heat1 = heats[0].clone(); + let before = state.read().unwrap().0.len(); + + // Heat 1 is still Scheduled — Advance is illegal. + let ack = apply_command_in_event( + ®istry, + &event_id, + &state, + Command::Advance { heat: heat1 }, + ); + assert!(!ack.ok, "Advance on a non-Final heat must be rejected"); + assert_eq!(ack.error.unwrap().code, ErrorCode::BadRequest); + assert_eq!( + state.read().unwrap().0.len(), + before, + "a rejected Advance appends nothing (no transition, no selection)" + ); + } + + /// Wire-compat (#216): an older `FillRound` payload with **no `mode`** deserializes to the + /// single-step default (`Next`), so existing clients keep working unchanged. + #[test] + fn fill_round_without_mode_defaults_to_next() { + let cmd: Command = + serde_json::from_str(r#"{ "FillRound": { "round": "qual-r1" } }"#).unwrap(); + assert_eq!( + cmd, + Command::FillRound { + round: gridfpv_events::RoundId("qual-r1".into()), + mode: FillMode::Next, + } + ); + } +} 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/events.rs b/crates/server/src/events.rs new file mode 100644 index 0000000..e22adde --- /dev/null +++ b/crates/server/src/events.rs @@ -0,0 +1,4216 @@ +//! Events as **first-class containers** — the `EventRegistry` and `EventMeta` (issue #72). +//! +//! An **event is the container** for a whole fact log: its heats, registrations, passes, +//! and marshaling adjudications all live in *one* event's append-only log, distinct from +//! every other event's. The flat single-log model (one Director = one event) is replaced +//! by a registry mapping each [`EventId`] to that event's own [`AppState`](crate::app::AppState) +//! — and therefore its own [`EventLog`]. Every read/realtime/control surface is rooted under +//! the event (`/events/{eventId}/…`); the registry resolves the id to the log that surface +//! serves (see [`crate::app::events_router`]). +//! +//! # Two physical realizations of one logical model +//! +//! The model is **backend-agnostic** — the registry stores an `AppState` over *any* +//! [`EventLog`] backend, so the same logical "an event owns a dense, per-event log" holds +//! across both realizations: +//! +//! - **Local (now):** each persistent event is its **own SQLite file** (one `log` table per +//! file, dense offsets from 0 — exactly the existing [`SqliteLog`](gridfpv_storage::SqliteLog) +//! schema). The built-in **Practice** event is an **in-memory** log +//! ([`InMemoryLog`](gridfpv_storage::InMemoryLog)), non-persistent by design. +//! - **Cloud (v0.7 — NOT built here, kept compatible):** one Postgres DB with an `events` +//! table and a shared `event_log` table keyed by `event_id` with a **composite primary key +//! `(event_id, offset)`**, so each event's offset sequence stays **per-event dense** — +//! identical to "one SQLite file per event" today. Reads are always event-scoped (the +//! registry never serves across events), so the Postgres backend slots behind the same +//! `EventLog` trait: an event-scoped log handle is a `WHERE event_id = ?` view whose +//! `offset` column is dense within that event. No surface here reads the whole DB; every +//! path resolves an event first, so the per-event-dense-offset invariant is the only thing +//! the cloud mapping must preserve — and the composite PK gives it for free. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use gridfpv_engine::format::{FormatRegistry, OpenPractice}; +use gridfpv_engine::heat::{GraceWindow, ProtestWindow}; +use gridfpv_engine::scoring::WinCondition; +use gridfpv_events::RoundId; +use gridfpv_storage::{InMemoryLog, SqliteLog}; + +use crate::app::AppState; +use crate::auth::TokenStore; +use crate::classes::ClassDirectory; +use crate::pilots::PilotDirectory; +use crate::scope::{ClassId, EventId, PilotId}; +use crate::timers::{MOCK_TIMER_ID, TimerId, TimerRegistry}; + +/// The reserved id of the always-present built-in **Practice** event. +/// +/// Practice is seeded into every registry, backed by an in-memory (non-persistent) log: +/// the RD can run a sim race with nothing configured. Its id is reserved — [`EventRegistry::create`] +/// auto-generates ids and never collides with it. +pub const PRACTICE_EVENT_ID: &str = "practice"; + +/// The display name of the built-in Practice event. +pub const PRACTICE_EVENT_NAME: &str = "Practice"; + +/// The file name (under the data dir) the Director's active-event id is persisted to (issue +/// #90), so the selected event survives a Director restart. +pub const ACTIVE_EVENT_FILE: &str = "active-event"; + +/// The key, in an event's sidecar `meta` table, under which its [`EventMeta`] is persisted +/// as JSON (issue #111). Stored on create and re-written on every meta mutation +/// (`set_timers`/`set_primary_timer`/…) so a Director restart restores the latest config. +pub const EVENT_META_KEY: &str = "event_meta"; + +/// The file-name suffix (and the only files the boot-scan opens) of a persistent event's +/// SQLite log under the data dir: `.sqlite` (issue #111). +const EVENT_DB_SUFFIX: &str = ".sqlite"; + +/// The metadata describing one event in the registry (issue #72). +/// +/// The wire shape `GET /events` returns: a stable [`EventId`], a human display `name`, the +/// creation time, and whether the event is **persistent** (file-backed) or ephemeral (the +/// in-memory Practice event). Derives serde (its JSON *is* the wire form) and `ts_rs::TS` +/// so the frontend reads a generated `EventMeta` type. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct EventMeta { + /// The stable handle every per-event route is rooted under (`/events/{id}/…`). + pub id: EventId, + /// The human-readable display name (names are display-only; the id is authoritative). + pub name: String, + /// Creation time in **milliseconds since the Unix epoch** (a plain JSON number — bounded + /// far below 2^53, rendered as a TS `number` not a `bigint`, matching every other integer + /// on the wire). Practice is seeded at registry construction. + #[ts(type = "number")] + pub created_at: i64, + /// Whether the event's log is durable (a SQLite file) or ephemeral (the in-memory + /// Practice log, `false`). + pub persistent: bool, + /// Optional **display date** of the event, as a free-form string (e.g. `"2026-06-20"` or + /// `"Sat 20 Jun"`). A string, not an epoch — it is a *human label the RD types*, shown + /// verbatim on the picker and (later) the context header; the authoritative machine + /// timestamp is [`created_at`](Self::created_at). Omitted from the wire when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub date: Option, + /// Optional venue / location label (e.g. `"Main field"`). Omitted from the wire when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub location: Option, + /// Optional free-text description / notes for the event. Omitted from the wire when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + /// Optional organizer name (the running club / person). Omitted from the wire when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub organizer: Option, + /// The application-level timers this event **selects** (issue #73) — the per-event reference + /// into the app-level [`TimerRegistry`](crate::timers::TimerRegistry). Additive + /// (`#[serde(default)]`) so an event persisted before #73 reads back with an empty list; new + /// events and Practice default to `["mock"]` (the built-in Mock) so they work out of the + /// box. The per-event source bridge runs the selected Sim timers; a selected RotorHazard is a + /// reserved no-op stub (2b / #65). + #[serde(default)] + pub timers: Vec, + /// The **primary** timer among the selection (issue #112) — redundant timers at one gate, one + /// designated **primary** and the rest **alternates**. The per-event source bridge feeds **only + /// the active source's** passes into the log (the primary while it's healthy; on a primary drop + /// it fails over to the first healthy alternate; on primary recovery it switches back), so two + /// timers at the same gate give redundancy without double-counting the same crossing. + /// + /// Additive (`#[serde(default)]`) so an event persisted before #112 reads back with `None`. + /// When unset, the **first** selected timer is the effective primary (see + /// [`EventMeta::effective_primary`]). Must name a timer that is in [`timers`](Self::timers); a + /// primary not in the selection is ignored (the first selected timer is used instead). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub primary_timer: Option, + /// The event's **roster** (issue #74) — the application-level [`Pilot`](crate::pilots::Pilot)s + /// that race this event, by their [`PilotId`]. The per-event reference into the app-level + /// [`PilotDirectory`](crate::pilots::PilotDirectory): a Director maintains their pilots once, + /// and each event simply picks which of them race it (mirroring [`timers`](Self::timers)). + /// + /// Additive (`#[serde(default)]`) so an event persisted before #74 reads back with an empty + /// roster; new events and Practice default to an **empty** roster. Channels (which frequency a + /// roster pilot flies in a heat) are a separate concern (#117) and are not modelled here. + #[serde(default)] + pub roster: Vec, + /// The application-level **classes** this event runs (issue #84) — the per-event reference into + /// the app-level [`ClassDirectory`](crate::classes::ClassDirectory), by their [`ClassId`]. The + /// per-event selection into the app-level class directory: a Director maintains their racing + /// categories once, and each event simply picks which of them run at it (mirroring + /// [`roster`](Self::roster) and [`timers`](Self::timers)). + /// + /// Additive (`#[serde(default)]`) so an event persisted before #84 reads back with an empty + /// selection; new events and Practice default to an **empty** selection. This is the registry + /// slice only — the rounds / phase engine a class later drives is a separate concern. + #[serde(default)] + pub classes: Vec, + /// **Per-class membership** (race redesign Slice 1a) — which roster pilots race each + /// [`class`](Self::classes). Each [`ClassMembership`] pairs one selected class with the + /// [`PilotId`]s racing it; a roster pilot may be a member of several classes (or none). + /// + /// Distinct from the [`roster`](Self::roster) (who is *present at the event*) and from the + /// [`classes`](Self::classes) selection (which categories *run at all*): membership is the + /// finer join of the two — given the present pilots and the running classes, *who races + /// which class*. Set per class through + /// [`set_class_membership`](EventRegistry::set_class_membership). + /// + /// Additive (`#[serde(default)]`, omitted from the wire when empty) so an event persisted + /// before Slice 1a reads back with no membership; new events and Practice default to an + /// **empty** list. The whole field round-trips through the event's persisted meta (issue + /// #115), so it is restart-safe for free. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub classes_membership: Vec, + /// The event's **rounds** (race redesign Slice 2a) — the event-level, class-tagged, *dynamic* + /// format-instances this event runs. A [`RoundDef`] scopes a format (a + /// [`FormatRegistry`](gridfpv_engine::format::FormatRegistry) name) and its config to one or + /// more eligible [`classes`](Self::classes), with a [`SeedingRule`] for how the field is drawn. + /// Practice / qualifying rounds are added **as-you-go** through + /// [`add_round`](EventRegistry::add_round); brackets (later slices) seed from a prior round's + /// ranking via [`SeedingRule::FromRanking`]. + /// + /// Additive (`#[serde(default)]`, omitted from the wire when empty) so an event persisted before + /// Slice 2a reads back with no rounds; new events and Practice default to an **empty** list. The + /// whole field round-trips through the event's persisted meta (issue #115), so it is restart-safe + /// for free. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rounds: Vec, +} + +/// One class's **membership** within an event (race redesign Slice 1a): the roster pilots that +/// race a single [`ClassId`]. +/// +/// Carried in [`EventMeta::classes_membership`] as a list, one entry per class with any members. +/// Derives serde (its JSON *is* the wire form) and `ts_rs::TS` so the frontend reads a generated +/// `ClassMembership` type for the per-class roster picker (the UI lands in Slice 1b). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct ClassMembership { + /// The class these pilots race — one of the event's selected [`classes`](EventMeta::classes). + pub class: ClassId, + /// The roster pilots racing this class, in selection order, **each with an optional assigned + /// channel** (race redesign Slice 7a). Each entry is a directory pilot that is also on the + /// event's [`roster`](EventMeta::roster); its [`channel`](MemberSlot::channel) is the raw-MHz + /// frequency the pilot flies in a *static*-channel-mode round (a fixed, per-membership channel, + /// GQ-style). + /// + /// **Legacy-compatible:** older events persisted this as a bare `Vec`; the + /// [`MemberSlot`] (de)serialises through a serde shim ([`member_slots`]) that accepts either + /// shape — a plain pilot-id string (legacy) reads back as a [`MemberSlot`] with no channel — so + /// pre-Slice-7a meta still loads and restart round-trips. + #[serde(with = "member_slots")] + #[ts(as = "Vec")] + pub pilots: Vec, +} + +/// One pilot's **slot** within a class's membership (race redesign Slice 7a): the directory pilot +/// plus the optional raw-MHz channel they fly in a *static*-channel-mode round. +/// +/// The channel is the GQ-style **fixed, per-membership** assignment: in a [`ChannelMode::Static`] +/// round (time-trial / qualifying), every member flies their own channel and qual heats are +/// channel-balanced (one pilot per channel per heat). It is `None` until set, and is unused by a +/// [`ChannelMode::PerHeat`] round (the bracket path assigns channels per heat). Validated on set +/// against the event's **primary timer**'s `available_channels` — and that pool **can exceed** the +/// timer's `node_count`, so any channel in the pool is valid (node_count caps only pilots-per-heat). +/// +/// Derives serde + `ts_rs::TS` so the frontend reads a generated `MemberSlot` for the Classes & +/// Roster channel picker (the UI is the Slice-7b follow-up). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct MemberSlot { + /// The directory pilot racing this class. + pub pilot: PilotId, + /// The pilot's **fixed assigned channel** (raw MHz) for *static*-channel-mode rounds, or `None` + /// when unassigned. Must be one of the event's **primary timer**'s `available_channels` + /// (validated on set) — the pool may be larger than `node_count`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub channel: Option, +} + +impl MemberSlot { + /// A slot for `pilot` with no channel assigned yet (the legacy / freshly-added shape). + pub fn new(pilot: PilotId) -> Self { + Self { + pilot, + channel: None, + } + } +} + +/// Serde shim letting [`ClassMembership::pilots`] (de)serialise as **either** the current +/// `Vec` *or* the legacy `Vec` (race redesign Slice 7a). +/// +/// On read, each element is accepted as either a full `MemberSlot` object (`{ "pilot": …, "channel": +/// … }`) **or** a bare pilot-id string — a legacy `["acroace-1", …]` array loads as channel-less +/// slots, so pre-Slice-7a persisted meta round-trips. On write, the canonical `MemberSlot` form is +/// always emitted (a freshly-saved event is never legacy-shaped). Kept restart-safe for free since +/// it rides the existing meta JSON. +mod member_slots { + use super::{MemberSlot, PilotId}; + use serde::Deserialize; + use serde::de::{Deserializer, SeqAccess, Visitor}; + use serde::ser::Serializer; + use std::fmt; + + /// One element of the legacy-or-current membership list: a bare pilot id (legacy) or a full slot. + #[derive(Deserialize)] + #[serde(untagged)] + enum SlotOrId { + /// The legacy shape — a bare pilot-id string; reads back as a channel-less slot. + Id(PilotId), + /// The current shape — a full `{ pilot, channel? }` object. + Slot(MemberSlot), + } + + impl From for MemberSlot { + fn from(value: SlotOrId) -> Self { + match value { + SlotOrId::Id(pilot) => MemberSlot::new(pilot), + SlotOrId::Slot(slot) => slot, + } + } + } + + /// Always serialise the canonical `Vec` form. + pub fn serialize( + slots: &[MemberSlot], + serializer: S, + ) -> Result { + serializer.collect_seq(slots) + } + + /// Deserialise a sequence whose elements are each a bare id (legacy) or a full slot. + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + struct SlotsVisitor; + + impl<'de> Visitor<'de> for SlotsVisitor { + type Value = Vec; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a list of member slots or bare pilot ids") + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0)); + while let Some(element) = seq.next_element::()? { + out.push(element.into()); + } + Ok(out) + } + } + + deserializer.deserialize_seq(SlotsVisitor) + } +} + +/// One **round** within an event (race redesign Slice 2a): an event-level, class-tagged, *dynamic* +/// format-instance. +/// +/// A round is a *format-instance* — a named, configured run of one +/// [`FormatRegistry`](gridfpv_engine::format::FormatRegistry) format, scoped to the eligible +/// [`classes`](Self::classes) it runs for, with a [`SeedingRule`] deciding how its field is drawn. +/// One eligible class is a **class round** (e.g. "Open Qualifying"); many/all classes is an +/// **open / practice** round. Rounds are added **as-you-go** (practice/quali) rather than +/// precomputed; later slices seed brackets from a prior round's ranking +/// ([`SeedingRule::FromRanking`]). +/// +/// Carried in [`EventMeta::rounds`]. Derives serde (its JSON *is* the wire form) and `ts_rs::TS` so +/// the frontend reads a generated `RoundDef` type (the Rounds UI lands in Slice 2b). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct RoundDef { + /// The stable, **auto-generated** handle for this round (a slug of the [`label`](Self::label) + /// plus a short random suffix — mirroring the event/pilot id-gen). Never user-entered; the + /// label is display-only. Referenced by a later round's [`SeedingRule::FromRanking`]. + pub id: RoundId, + /// The human-readable label (e.g. `"Qualifying R1"`, `"Open Practice"`). Display-only; the + /// [`id`](Self::id) is authoritative. + pub label: String, + /// The eligible [`classes`](EventMeta::classes) this round runs for. One class is a *class + /// round*; many/all is an *open / practice* round. Each must be one of the event's selected + /// classes. + pub classes: Vec, + /// The format this round runs — a known + /// [`FormatRegistry`](gridfpv_engine::format::FormatRegistry) name (e.g. `"timed_qual"`, + /// `"single_elim"`). Validated against [`FormatRegistry::standard`] on add/update. + pub format: String, + /// The format's config knobs (e.g. `rounds`, `advance`, `heat_size`), stored as-is as a + /// string→string map — the same shape a `FormatConfig`'s params take. Stored verbatim with only + /// light validation; format-specific interpretation is the engine's concern when the round runs + /// (a later slice). + pub params: BTreeMap, + /// How a heat in this round is won — the per-round scoring rule (the existing wire + /// [`WinCondition`](gridfpv_engine::scoring::WinCondition)). + /// + /// **Open practice does no scoring**, so a win condition is not *required* for an open-practice + /// round: the create/update requests make this field optional ([`NewRoundReq::win_condition`]) + /// and an omitted condition stores an inert [`default_win_condition`] here. The field stays a + /// plain [`WinCondition`] (not an `Option`) so every scoring path is unchanged — for a + /// non-open-practice round the stored condition is the one the RD chose; for an open-practice + /// round it is never consulted (the heat ends on the [`time_limit_secs`](Self::time_limit_secs) + /// or the RD's `ForceEnd`). + pub win_condition: WinCondition, + /// How this round's field is **seeded** (drawn). Defaults to [`SeedingRule::FromRoster`] (the + /// eligible classes' membership, in roster order); a bracket round seeds from a prior round's + /// ranking ([`SeedingRule::FromRanking`], consumed in a later slice). + pub seeding: SeedingRule, + /// How this round assigns **video channels** to its heats (race redesign Slice 7a). A + /// [`ChannelMode::Static`] round (time-trial / qual, GQ-style) uses each member's *fixed* + /// per-membership channel ([`MemberSlot::channel`]) and forms channel-balanced heats; a + /// [`ChannelMode::PerHeat`] round (brackets) assigns channels per heat from the timer's pool + /// (first-fit). Defaulted **by format** on create (`#[serde(default)]` so pre-Slice-7a meta + /// reads back as [`ChannelMode::PerHeat`], the prior behaviour); RD-overridable. + #[serde(default)] + pub channel_mode: ChannelMode, + /// The **staging timer** for this round, in seconds (heat-lifecycle Slice 2). *Informational + /// only* — there is **no** auto-advance out of `Staged`; the console displays it as a staging + /// countdown (Slice 3). Defaults to [`default_staging_timer_secs`] (300s = 5 min). Additive + /// (`#[serde(default)]`) so pre-Slice-2 meta reads back with the default. + #[serde(default = "default_staging_timer_secs")] + pub staging_timer_secs: u32, + /// The **start procedure** for this round (heat-lifecycle Slice 2) — how the heat auto-advances + /// `Armed → Running`. The runtime picks a randomized delay in the procedure's window once, logs + /// it ([`Event::HeatStarting`](gridfpv_events::Event::HeatStarting)), and fires the transition + /// then. Defaults to a sane randomized delay ([`StartProcedure::default`]). Additive. + #[serde(default)] + pub start_procedure: StartProcedure, + /// The **grace window** for late crossings after the win condition is met (heat-lifecycle + /// Slice 2). The runtime holds the heat in `Running` for this long after the race-end criterion + /// before appending the auto `Running → Unofficial`, so trailing pilots' final laps still count. + /// Defaults to [`default_grace_window`] (a bounded few seconds — *not* `UntilScored`, so the + /// auto-completion actually fires). Additive. + #[serde(default = "default_grace_window")] + pub grace_window: GraceWindow, + /// The **protest window** for the provisional → official lifecycle (marshaling Slice 5, + /// marshaling.html §3.3) — an optional, **OFF-by-default auto-official timer**. When set to + /// [`ProtestWindow::After`], the runtime auto-finalizes the heat (`Unofficial → Final`) once the + /// window elapses from the race-end instant; the RD can always finalize early or correct during + /// the window, and `Revert` re-opens a finalized result. The default [`ProtestWindow::Off`] is + /// today's behaviour — **manual `Finalize` only**, nothing auto-finalizes. + /// + /// Per-round so it can vary by phase (e.g. a protest window on the mains, none on practice). + /// Additive (`#[serde(default)]`) so a round persisted before this field reads back as `Off`. + #[serde(default)] + pub protest_window: ProtestWindow, + /// The **minimum lap time** floor, in seconds (D26) — GridFPV-native, because timers are + /// dumb pass emitters and GridFPV owns lap semantics: a raw pass that would close a lap + /// shorter than this (a gate reflection, a double-detection) is AUTO-SUPPRESSED by the + /// corrected-passes fold — visible on the marshaling lap list as a struck removal-record + /// row with a Restore override (marshal-created passes — inserts, re-times — are exempt: + /// an explicit ruling always outranks the floor). `None`/`0` = off (rounds predating the + /// field keep their scored results bit-identical). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_lap_secs: Option, + /// The **practice duration** for an open-practice round, in seconds (open-practice refinement). + /// When set, the runtime clock **auto-ends the practice** (`Running → Unofficial`) once the + /// heat's elapsed running time reaches this limit — independent of any win condition (the time is + /// the *only* end condition for an open-practice heat). When unset (`None`), the practice runs + /// until the RD ends it (`ForceEnd`). E.g. `3600` ends a one-hour practice on its own. + /// + /// Additive (`#[serde(default)]`, omitted from the wire when unset) so a round persisted before + /// this field reads back with `None`. Only consulted for an open-practice heat; a normal round + /// keeps ending on its win condition. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub time_limit_secs: Option, +} + +/// The **inert default win condition** stored on a round whose create/update request omitted one +/// (open-practice refinement): a [`WinCondition::BestLap`]. +/// +/// Open practice does no scoring, so the stored condition is never consulted for an open-practice +/// round — it ends on its [`time_limit_secs`](RoundDef::time_limit_secs) or the RD's `ForceEnd`. +/// Keeping [`RoundDef::win_condition`] a plain (non-`Option`) [`WinCondition`] means every scoring +/// path is unchanged; this just gives the field a harmless value when the form supplies none. +pub fn default_win_condition() -> WinCondition { + WinCondition::BestLap +} + +/// The default [`RoundDef::staging_timer_secs`] — **300s (5 minutes)** of staging (heat-lifecycle +/// Slice 2). Informational only (no auto-advance); the console renders it as a staging countdown. +pub fn default_staging_timer_secs() -> u32 { + 300 +} + +/// The default [`RoundDef::grace_window`] — a **bounded 30-second** window after the win condition +/// is met (heat-lifecycle Slice 2). +/// +/// Deliberately a [`GraceWindow::Duration`], **not** the open-ended [`GraceWindow::UntilScored`]: +/// the runtime's completion clock must eventually fire the `Running → Unofficial` auto-transition, +/// so the grace window has to close on its own. Thirty seconds comfortably covers a trailing pilot +/// finishing the lap (or the last lap of a multi-lap final) they were on when the leader met the +/// criterion, while still ending the heat on its own. RD-configurable per round. +pub fn default_grace_window() -> GraceWindow { + GraceWindow::Duration { micros: 30_000_000 } +} + +/// The **start procedure** that drives a heat's `Armed → Running` auto-transition (heat-lifecycle +/// Slice 2). +/// +/// An **extensible** enum (today the one [`RandomizedDelay`](Self::randomized-delay) mode): the +/// runtime reads it when a heat enters `Armed`, picks a delay in the procedure's window **once**, +/// writes it to the log as a fact ([`Event::HeatStarting`](gridfpv_events::Event::HeatStarting)), +/// and schedules the `Running` transition for then. Randomization happens only in the runtime at +/// emission time, never in the fold, so a replay reads the logged delay and reproduces the start +/// exactly (race-engine.html §6). Derives serde + `ts_rs::TS`. +/// +/// Serialized **internally tagged** on `mode` (e.g. `{ "mode": "randomized-delay", ... }`) so a +/// future mode (a fixed countdown, an external arm-tone trigger) is an additive variant. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(tag = "mode")] +#[ts(export, export_to = "bindings/")] +pub enum StartProcedure { + /// **Randomized hold-then-go** (the FPV "arm… and… go" with a random hold): the runtime + /// picks a delay uniformly in `[min_delay_ms, max_delay_ms]` and starts the race then. This is + /// the canonical FPV start where pilots are armed and the go-tone comes after an + /// unpredictable hold. + #[serde(rename = "randomized-delay")] + RandomizedDelay { + /// The shortest the runtime will hold before `Running`, in milliseconds. + #[ts(type = "number")] + min_delay_ms: u32, + /// The longest the runtime will hold before `Running`, in milliseconds. Must be ≥ + /// `min_delay_ms` (the runtime clamps a mis-ordered pair to a point delay defensively). + #[ts(type = "number")] + max_delay_ms: u32, + /// Optional start-tone cue config for the console (heat-lifecycle Slice 3 renders/plays it; + /// stored here now). Absent ⇒ the console uses its default tone. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + tone: Option, + }, +} + +impl Default for StartProcedure { + /// A sane default randomized delay — a **2000–5000ms** hold before the go (heat-lifecycle + /// Slice 2), the canonical FPV start window. + fn default() -> Self { + StartProcedure::RandomizedDelay { + min_delay_ms: 2000, + max_delay_ms: 5000, + tone: None, + } + } +} + +/// The start-tone cue for a [`StartProcedure`] (heat-lifecycle Slice 2) — stored config the console +/// uses to play the go-tone (the audio UX lands in Slice 3). Kept minimal and additive. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct StartTone { + /// The tone frequency in hertz (e.g. `880`). Absent fields default in the console. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub hz: Option, + /// The tone duration in milliseconds (e.g. `400`). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub ms: Option, +} + +/// How a [`RoundDef`] assigns **video channels** to its heats (race redesign Slice 7a). +/// +/// Two models, chosen by the round's competition shape: +/// +/// - [`Static`](Self::Static) — **time-trial / qualifying** (GQ-style): every member has a *fixed* +/// channel assigned at membership ([`MemberSlot::channel`], drawn from the event's **primary +/// timer**'s `available_channels`). Heats are **channel-balanced** — one pilot per channel per +/// heat, ≤ `node_count` pilots per heat — so every member flies across the format's rounds. +/// - [`PerHeat`](Self::PerHeat) — **brackets**: the bracket decides matchups, so channels are +/// assigned **per heat** from the timer's pool (the existing first-fit allocation), each heat ≤ +/// `node_count` pilots. +/// +/// Defaulted by format on round-create (`timed_qual` / `round_robin` → [`Static`](Self::Static); +/// the elimination / multi-main formats → [`PerHeat`](Self::PerHeat)); the default `Default` impl is +/// [`PerHeat`](Self::PerHeat) so a round persisted before this field existed reads back with the +/// prior per-heat behaviour. Derives serde + `ts_rs::TS`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum ChannelMode { + /// Channel-balanced heats from each member's fixed, per-membership channel (time-trial / qual). + Static, + /// Per-heat channel assignment (brackets) — the default and the pre-Slice-7a behaviour. + #[default] + PerHeat, +} + +impl ChannelMode { + /// The **default channel mode for a format** (race redesign Slice 7a): the static, fixed-channel + /// qualifying formats (`timed_qual`, `round_robin`) default to [`Static`](Self::Static); every + /// other format (the elimination brackets, multi-main, zippyq) defaults to + /// [`PerHeat`](Self::PerHeat). The RD can override the default per round. + pub fn default_for_format(format: &str) -> Self { + match format { + "timed_qual" | "round_robin" => ChannelMode::Static, + _ => ChannelMode::PerHeat, + } + } +} + +/// How a [`RoundDef`]'s field is **seeded** (race redesign Slice 2a). +/// +/// A round either draws its field straight from the eligible classes' roster membership +/// ([`FromRoster`](Self::FromRoster) — practice / first qualifying), or from a **prior round's +/// ranking** ([`FromRanking`](Self::FromRanking) — the bracket / cut case, the issue-#84 carry that +/// a later slice consumes), or — for the casual **open-practice** format — from a set of active +/// **channels** ([`AllChannels`](Self::AllChannels)) rather than pilots. Derives serde + `ts_rs::TS`. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum SeedingRule { + /// Seed from the eligible classes' roster membership, in roster order. The default for + /// practice and first-qualifying rounds. + #[default] + FromRoster, + /// Seed from the **top-N** of one or more prior rounds' aggregated ranking — the bracket / cut + /// seam (issue #51 multi-select, issue #84 carry). Each entry of `source_rounds` must name + /// another [`RoundDef`] in the same event's [`rounds`](EventMeta::rounds); `top_n` is how many + /// advance. When several rounds are named, the field is seeded from the **best-per-pilot + /// ranking aggregated across those rounds** (see the round engine's `round_field`). + /// + /// **Serde back-compat:** an older stored round wrote a single `source_round: "x"` string; + /// the enum's hand-written [`Deserialize`](SeedingRule#impl-Deserialize) reads either the + /// legacy `source_round` key (→ `source_rounds: ["x"]`) or the current `source_rounds` array. + /// Purely additive — no data migration. + FromRanking { + /// The prior rounds this round seeds from — each must exist in [`EventMeta::rounds`]. + /// Aggregated best-per-pilot when more than one is named. Always at least one entry. + source_rounds: Vec, + /// How many of the aggregated ranking's top places advance into this round. + top_n: usize, + }, + /// Seed from the **heat winners** of a prior bracket-level round — the **bracket + /// advancement** carry (decisions D13, #217). The field is each completed heat's + /// **advancing** competitor(s) in `source_round`, taken **in heat order** (heat 0's winner + /// first, then heat 1's, …), plus any bye competitor the level advanced. This is exactly how + /// a single-elimination bracket advances **round-to-round** under the level-per-round model: a + /// level is one round, and the *next* level is a new round seeded from the prior level's + /// winners — no intra-round bracket-walking. + /// + /// "Winner" means the heat's **advancing set** under the source round's format (the heat's top + /// half — head-to-head advances one, a 4-up heat advances two), so a 4-up bracket carries the + /// right competitors forward. The order is the source level's + /// [`round_ranking`](crate::round_engine::round_ranking) advancers prefix, which a single-elim + /// level already lists winners-first in heat order. Unlike [`FromRanking`](Self::FromRanking) + /// (which takes a *top-N* slice of an aggregated ranking) this takes **exactly the winners** — + /// however many heats the level had — so the next level's size follows the bracket, not a + /// fixed `top_n`. Single source only (a single-elim level feeds exactly one next level); + /// double-elimination's cross-bracket losers-of feed is a separate, deferred design (D13). + FromHeatWinners { + /// The prior bracket-level round whose heat winners seed this round — must exist in + /// [`EventMeta::rounds`]. + source_round: RoundId, + }, + /// Seed from a **slice** of one or more prior rounds' aggregated ranking — the multi-main / + /// consolation seam (MultiGP multi-main). Like [`FromRanking`](Self::FromRanking) the field is + /// the **best-per-pilot ranking aggregated across `source_rounds`** (see the round engine's + /// `resolve_seeding`), but rather than the top-N it takes the window `skip+1 ..= skip+take` of + /// that ranking — e.g. a C-main seeded from qual seeds 13–20 is `skip: 12, take: 8`. Each entry + /// of `source_rounds` must name another [`RoundDef`] in the same event; `take` must be `> 0`. + /// + /// **Serde back-compat:** mirrors [`FromRanking`](Self::FromRanking)'s lenient body — the + /// enum's hand-written [`Deserialize`](SeedingRule#impl-Deserialize) reads either the current + /// `source_rounds` array or a legacy single `source_round` string. Purely additive. + FromRankingRange { + /// The prior rounds this round seeds from — each must exist in [`EventMeta::rounds`]. + /// Aggregated best-per-pilot when more than one is named. Always at least one entry. + source_rounds: Vec, + /// How many of the aggregated ranking's leading places to **skip** before taking — the + /// 0-based start of the window (seed 13 is `skip: 12`). + skip: usize, + /// How many places to take after the skip — the window width. Must be `> 0`. + take: usize, + }, + /// Seed from the **union of sub-sources** — the composition primitive a real MultiGP multi-main + /// wires per-main brackets bottom-up with (e.g. a B-main = the next six qual seeds **plus** the + /// top two out of the C-main final). Each sub-rule in `sources` is resolved independently and + /// the results are **concatenated in order**, de-duplicated keeping each competitor's **first** + /// occurrence — so a competitor that two sources both name is seeded once, at the earlier + /// source's position. `sources` must be non-empty; sub-rules may themselves be `Combine` + /// (serde handles the self-reference), bounded by a nesting-depth cap at add/update. + Combine { + /// The sub-rules whose fields are concatenated (in order) then de-duplicated first-wins. + /// Each is resolved exactly as a standalone seeding rule. Always at least one entry. + sources: Vec, + }, + /// Seed from a set of active **channels** rather than pilots — the **open-practice** seeding + /// (open-practice format). The field builder lays each node index out as a `node-{i}` + /// [`CompetitorRef`](gridfpv_events::CompetitorRef) (the timer-seat handle the timer emits + /// passes for), and the one open heat runs over those channels with per-channel laps tracked + /// **live in memory, not logged**. An open-practice round is `format: "open_practice"` + + /// `seeding: AllChannels { channels }`; its [`classes`](RoundDef::classes) may be empty (it is + /// not a class round). Additive variant — pre-existing rounds (`FromRoster`/`FromRanking`) read + /// back unchanged. + AllChannels { + /// The active channels as **node indices** (the timer-seat indices the RD made live), laid + /// out as `node-{i}` competitor refs by the field builder, in this order. + channels: Vec, + }, +} + +/// Hand-written [`Deserialize`] for [`SeedingRule`] that mirrors serde's default externally-tagged +/// representation while accepting **both** the current `source_rounds: [...]` shape and the legacy +/// single `source_round: "x"` shape for [`FromRanking`](SeedingRule::FromRanking) (issue #51 +/// back-compat). The legacy string is lifted to a one-element `source_rounds` — additive, no data +/// migration. Every other variant deserializes exactly as the derive would. +impl<'de> Deserialize<'de> for SeedingRule { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error as _; + + // The body of the `FromRanking` variant, accepting either `source_rounds` (current) or the + // legacy `source_round` single string. `deny_unknown_fields` keeps a typo from silently + // seeding from an empty set; exactly one of the two source keys must be present. + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct FromRankingBody { + #[serde(default)] + source_rounds: Option>, + #[serde(default)] + source_round: Option, + top_n: usize, + } + + // `FromHeatWinners` is single-source (`source_round`), but for consistency with + // `FromRanking` (which takes both) we also accept a one-element `source_rounds` array — so a + // caller who pluralises by habit isn't rejected. Exactly one source must resolve. + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct FromHeatWinnersBody { + #[serde(default)] + source_round: Option, + #[serde(default)] + source_rounds: Option>, + } + + // `FromRankingRange` mirrors `FromRanking`'s lenient body (current `source_rounds` array or + // legacy single `source_round` string) and adds the `skip` / `take` window. + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct FromRankingRangeBody { + #[serde(default)] + source_rounds: Option>, + #[serde(default)] + source_round: Option, + skip: usize, + take: usize, + } + + // An untagged shadow of the externally-tagged enum. `FromRanking` / `FromRankingRange` / + // `FromHeatWinners` carry the lenient bodies; the other variants reuse the same field shapes + // as `SeedingRule` so they round-trip 1:1. `Combine`'s `sources` is `Vec` — the + // recursive self-reference dispatches back through this same hand-written `Deserialize`. + #[derive(Deserialize)] + enum Shadow { + FromRoster, + FromRanking(FromRankingBody), + FromRankingRange(FromRankingRangeBody), + FromHeatWinners(FromHeatWinnersBody), + Combine { sources: Vec }, + AllChannels { channels: Vec }, + } + + match Shadow::deserialize(deserializer)? { + Shadow::FromRoster => Ok(SeedingRule::FromRoster), + Shadow::FromHeatWinners(body) => { + let source_round = match (body.source_round, body.source_rounds) { + // Canonical singular form wins. + (Some(single), _) => single, + // A one-element `source_rounds` is lifted to the single source. + (None, Some(rounds)) if rounds.len() == 1 => { + rounds.into_iter().next().expect("len == 1") + } + (None, Some(_)) => { + return Err(D::Error::custom( + "FromHeatWinners seeding takes a single `source_round` (a one-element `source_rounds` is also accepted)", + )); + } + (None, None) => { + return Err(D::Error::custom( + "FromHeatWinners seeding requires `source_round` (or a one-element `source_rounds`)", + )); + } + }; + Ok(SeedingRule::FromHeatWinners { source_round }) + } + Shadow::AllChannels { channels } => Ok(SeedingRule::AllChannels { channels }), + Shadow::Combine { sources } => Ok(SeedingRule::Combine { sources }), + Shadow::FromRankingRange(body) => { + let source_rounds = match (body.source_rounds, body.source_round) { + // Current shape — the explicit list wins. + (Some(rounds), _) => rounds, + // Legacy shape — lift the single source to a one-element list. + (None, Some(single)) => vec![single], + (None, None) => { + return Err(D::Error::custom( + "FromRankingRange seeding requires `source_rounds` (or legacy `source_round`)", + )); + } + }; + Ok(SeedingRule::FromRankingRange { + source_rounds, + skip: body.skip, + take: body.take, + }) + } + Shadow::FromRanking(body) => { + let source_rounds = match (body.source_rounds, body.source_round) { + // Current shape — the explicit list wins. + (Some(rounds), _) => rounds, + // Legacy shape — lift the single source to a one-element list. + (None, Some(single)) => vec![single], + (None, None) => { + return Err(D::Error::custom( + "FromRanking seeding requires `source_rounds` (or legacy `source_round`)", + )); + } + }; + Ok(SeedingRule::FromRanking { + source_rounds, + top_n: body.top_n, + }) + } + } + } +} + +/// The body of `POST /events/{id}/rounds` — everything a caller supplies to add a round (race +/// redesign Slice 2a). +/// +/// The **id is always auto-generated** (a slug of `label` plus a short random suffix), never +/// user-entered — mirroring the event/pilot create rule. The [`seeding`](Self::seeding) defaults to +/// [`SeedingRule::FromRoster`] when omitted. The route returns the created [`RoundDef`] (with its +/// generated [`id`](RoundDef::id)). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct NewRoundReq { + /// The display label for the new round (e.g. `"Qualifying R1"`). + pub label: String, + /// The eligible classes this round runs for. Each must be one of the event's selected classes. + pub classes: Vec, + /// The format this round runs — a known [`FormatRegistry`] name. + pub format: String, + /// The format's config knobs, stored verbatim. + #[serde(default)] + pub params: BTreeMap, + /// How a heat in this round is won. **Optional** (open-practice refinement): an open-practice + /// round does no scoring, so the form is not forced to supply one — **omit it** to store the + /// inert [`default_win_condition`] ([`WinCondition::BestLap`]). A normal round supplies its + /// chosen condition. Additive on the wire (a pre-existing client always sends it). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub win_condition: Option, + /// How the round's field is seeded; defaults to [`SeedingRule::FromRoster`] when omitted. + #[serde(default)] + pub seeding: SeedingRule, + /// The **practice duration** in seconds for an open-practice round (open-practice refinement). + /// Optional — omit (or leave blank) for **no time limit** (the RD ends the practice with + /// `ForceEnd`); supply it to have the runtime auto-end the practice at the limit. Stored on + /// [`RoundDef::time_limit_secs`]. Additive on the wire. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub time_limit_secs: Option, + /// How this round assigns channels (race redesign Slice 7a). Optional — **omit it** to take the + /// format's default ([`ChannelMode::default_for_format`]); supply it to override (e.g. force a + /// qual round per-heat). Additive on the wire. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub channel_mode: Option, + /// The round's staging timer in seconds (heat-lifecycle Slice 2). Optional — omit for the + /// [`default_staging_timer_secs`] (300). Informational only (no auto-advance). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub staging_timer_secs: Option, + /// The round's start procedure (heat-lifecycle Slice 2). Optional — omit for the + /// [`StartProcedure::default`] randomized 2000–5000ms delay. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub start_procedure: Option, + /// The round's grace window (heat-lifecycle Slice 2). Optional — omit for the + /// [`default_grace_window`] (a bounded 3s). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub grace_window: Option, + /// The round's protest window (marshaling Slice 5). Optional — omit for the default + /// [`ProtestWindow::Off`] (manual finalize only); supply [`ProtestWindow::After`] to arm the + /// auto-official timer. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub protest_window: Option, + /// Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. + #[serde(default)] + #[ts(optional)] + pub min_lap_secs: Option, +} + +/// The body of `PUT /events/{id}/rounds/{round}` — the editable fields of an existing round (race +/// redesign Slice 2a). The round's [`id`](RoundDef::id) is the path segment and is **not** editable; +/// every other field is replaced wholesale. Same validation as the add path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct UpdateRoundReq { + /// The new display label. + pub label: String, + /// The new eligible classes. Each must be one of the event's selected classes. + pub classes: Vec, + /// The new format — a known [`FormatRegistry`] name. + pub format: String, + /// The new format config knobs, stored verbatim. + #[serde(default)] + pub params: BTreeMap, + /// The new win condition. **Optional** (open-practice refinement): omit it to store the inert + /// [`default_win_condition`] (an open-practice round does no scoring). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub win_condition: Option, + /// The new seeding rule; defaults to [`SeedingRule::FromRoster`] when omitted. + #[serde(default)] + pub seeding: SeedingRule, + /// The new practice duration in seconds (open-practice refinement). Optional — omit for **no + /// time limit**. Stored on [`RoundDef::time_limit_secs`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub time_limit_secs: Option, + /// The new channel mode (race redesign Slice 7a). Optional — **omit it** to take the format's + /// default ([`ChannelMode::default_for_format`]); supply it to override. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub channel_mode: Option, + /// The new staging timer in seconds (heat-lifecycle Slice 2). Optional — omit for the + /// [`default_staging_timer_secs`] (300). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub staging_timer_secs: Option, + /// The new start procedure (heat-lifecycle Slice 2). Optional — omit for the + /// [`StartProcedure::default`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub start_procedure: Option, + /// The new grace window (heat-lifecycle Slice 2). Optional — omit for the + /// [`default_grace_window`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub grace_window: Option, + /// The new protest window (marshaling Slice 5). Optional — omit for the default + /// [`ProtestWindow::Off`] (manual finalize only). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub protest_window: Option, + /// Minimum lap time floor in seconds (D26); omitted/0 ⇒ off. + #[serde(default)] + #[ts(optional)] + pub min_lap_secs: Option, +} + +impl EventMeta { + /// The event's **effective primary** timer (issue #112): the explicitly-set + /// [`primary_timer`](Self::primary_timer) when it is present *and still in the selection*, + /// else the **first** selected timer. `None` only when the event selects no timers at all. + /// + /// This is the single rule the source bridge and the API validation share so "the primary is + /// the first selected timer unless overridden" holds everywhere. A stale `primary_timer` (it + /// was deselected) gracefully degrades to the first selected timer rather than designating a + /// timer the event no longer uses. + pub fn effective_primary(&self) -> Option { + match &self.primary_timer { + Some(p) if self.timers.contains(p) => Some(p.clone()), + _ => self.timers.first().cloned(), + } + } +} + +/// The wire shape of `GET /active-event` — the **Director's currently-active event**, or +/// `null` when none is set (issue #90). +/// +/// The active event is **Director (server-side) state**: there is exactly one Race Director +/// running one event, so the selected event lives on the Director, not in each browser. Every +/// client reads this on connect/reload to resume into the workspace (or fall to the picker when +/// `null`). The `event` field is the full [`EventMeta`] so a client renders the context header +/// without a second round-trip. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct ActiveEvent { + /// The active event's metadata, or `null` when no event is active (→ the picker). + pub event: Option, +} + +/// The body of `PUT /active-event` — the id of the event to make the Director's active one +/// (issue #90). The id must name a known event, else a typed 404 (`UnknownScope`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct SetActiveEventRequest { + /// The event to make active. + pub id: EventId, +} + +/// The body of `PUT /events/{id}/roster` — the directory pilot ids that make up an event's +/// roster (issue #74). Each must name a pilot in the application-level +/// [`PilotDirectory`](crate::pilots::PilotDirectory), else a typed 404. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct SetEventRosterRequest { + /// The directory pilots this event rosters, in selection order. Each must name a known pilot. + pub pilot_ids: Vec, +} + +/// The body of `PUT /events/{id}/classes` — the directory class ids that make up an event's +/// selection (issue #84). Each must name a class in the application-level +/// [`ClassDirectory`](crate::classes::ClassDirectory), else a typed 404. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct SetEventClassesRequest { + /// The directory classes this event runs, in selection order. Each must name a known class. + pub ids: Vec, +} + +/// The body of `PUT /events/{id}/classes/{class}/membership` — the roster pilot ids that race a +/// single class (race redesign Slice 1a). The `class` is the path segment; each id here must name +/// a pilot in the [`PilotDirectory`](crate::pilots::PilotDirectory), else a typed 404. An empty +/// list clears the class's membership. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct SetClassMembershipRequest { + /// The roster pilots that race this class, in selection order, **each with an optional assigned + /// channel** (race redesign Slice 7a). Each must name a known pilot; each set + /// [`channel`](MemberSlot::channel) must be one of the event's **primary timer**'s + /// `available_channels` (which may exceed the timer's `node_count`). + /// + /// **Legacy-compatible:** an element may be a bare pilot-id string (the pre-Slice-7a wire shape), + /// read as a channel-less slot — so an old client / persisted body still sets membership. + #[serde(with = "member_slots")] + #[ts(as = "Vec")] + pub pilots: Vec, +} + +/// The body of `POST /events` — the only thing a caller supplies when creating an event. +/// +/// Just a display `name`; the **id is always auto-generated** (a slug of the name plus a +/// short random suffix), never user-entered, per the maintainer's rule. Keeping the id off +/// the wire means two events can share a name without colliding and a client can't squat a +/// reserved id (e.g. `practice`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct CreateEventRequest { + /// The display name for the new event. + pub name: String, + /// Optional **display date** stored on the new event's [`EventMeta::date`] (see there). + /// Omitted from the wire when unset — a name-only create stays a one-field body. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub date: Option, + /// Optional venue / location, stored on [`EventMeta::location`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub location: Option, + /// Optional free-text description, stored on [`EventMeta::description`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + /// Optional organizer name, stored on [`EventMeta::organizer`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub organizer: Option, +} + +/// One registered event: its metadata plus the [`AppState`] (its own log + append-notify, +/// the shared token store) every per-event surface serves against. +struct RegisteredEvent { + meta: EventMeta, + state: AppState, +} + +/// The registry of all events on this Director (issue #72) — the backend-agnostic +/// `EventRegistry` the routing layer resolves an [`EventId`] through. +/// +/// Maps each [`EventId`] to its [`AppState`] (and so its own [`EventLog`]). A built-in +/// **Practice** event ([`PRACTICE_EVENT_ID`], in-memory, non-persistent) is always present. +/// Created events get a file-backed [`SqliteLog`](gridfpv_storage::SqliteLog) under the +/// configured data dir (one file per event — the local realization of the per-event-dense +/// log; see the module docs for the Postgres mapping). Cloning shares the one registry (it +/// is `Arc>`), so it can be the axum router state cloned into every handler. +#[derive(Clone)] +pub struct EventRegistry { + inner: Arc>, +} + +/// The guarded interior: the event map, the shared token store, and where persistent event +/// DBs live. +struct Registry { + /// `EventId → RegisteredEvent`. A `BTreeMap` so listing is deterministic (Practice is + /// listed first explicitly regardless). + events: BTreeMap, + /// The one Director-wide auth authority, shared into every per-event [`AppState`]. + tokens: TokenStore, + /// The Director-wide application-level **timer registry** (issue #73). Like the token store, + /// it is one app-level authority the per-event selection ([`EventMeta::timers`]) references; + /// it lives here so the single router state ([`EventRegistry`]) exposes it to the Timers API + /// handlers without a second axum state type. Cloning shares the one registry. + timers: TimerRegistry, + /// The Director-wide application-level **pilot directory** (issue #74). Like the timer + /// registry, it is one app-level authority the per-event roster ([`EventMeta::roster`]) + /// references; it lives here so the single router state ([`EventRegistry`]) exposes it to the + /// Pilots API handlers without a second axum state type. Cloning shares the one directory. + pilots: PilotDirectory, + /// The Director-wide application-level **class directory** (issue #84). Like the pilot + /// directory, it is one app-level authority the per-event class selection + /// ([`EventMeta::classes`]) references; it lives here so the single router state + /// ([`EventRegistry`]) exposes it to the Classes API handlers without a second axum state type. + /// Cloning shares the one directory. + classes: ClassDirectory, + /// Directory persistent event SQLite files are created under; `None` ⇒ created events + /// fall back to an in-memory log (no data dir configured — non-durable). + data_dir: Option, + /// The Director's **currently-active event** (issue #90) — the one all clients resume + /// into on connect/reload. `None` ⇒ the picker. Persisted to `/active-event` + /// (when a data dir is configured) so it survives a Director restart; in-memory only with + /// no data dir. + active_event: Option, +} + +impl EventRegistry { + /// Build a registry seeded with the built-in Practice event, persisting created events + /// under `data_dir` when given. + /// + /// The Practice event is an in-memory, non-persistent log. When `data_dir` is `Some`, + /// [`create`](EventRegistry::create) writes a SQLite file per event there; when `None`, + /// created events fall back to an in-memory log (so the registry is still usable with no + /// configured storage — useful in tests and an unconfigured Director). + pub fn new(data_dir: Option) -> Result { + let tokens = TokenStore::new(); + let mut events = BTreeMap::new(); + + // Build the Director-wide application-level timer registry (issue #73): the built-in + // Mock (drawing its `laps`/`lap_ms` from the env defaults) plus any timers persisted + // to `/timers.json`. Shares the same data dir as the events. + let (sim_laps, sim_lap_ms) = sim_defaults(); + let timers = TimerRegistry::new(data_dir.clone(), sim_laps, sim_lap_ms) + .map_err(|e| RegistryError::io(format!("could not build timer registry: {e}")))?; + + // Build the Director-wide application-level pilot directory (issue #74): the pilots + // persisted to `/pilots.json` (empty on first boot — there is no built-in + // pilot). Shares the same data dir as the events and timers. + let pilots = PilotDirectory::new(data_dir.clone()) + .map_err(|e| RegistryError::io(format!("could not build pilot directory: {e}")))?; + + // Build the Director-wide application-level class directory (issue #84): the classes + // persisted to `/classes.json` (empty on first boot — there is no built-in + // class). Shares the same data dir as the events, timers, and pilots. + let classes = ClassDirectory::new(data_dir.clone()) + .map_err(|e| RegistryError::io(format!("could not build class directory: {e}")))?; + + // Seed Practice: an in-memory (non-persistent) log, sharing the one token store. + let practice_id = EventId(PRACTICE_EVENT_ID.to_string()); + let practice_state = AppState::with_tokens(InMemoryLog::new(), tokens.clone()); + events.insert( + practice_id.clone(), + RegisteredEvent { + meta: EventMeta { + id: practice_id, + name: PRACTICE_EVENT_NAME.to_string(), + created_at: now_millis(), + persistent: false, + date: None, + location: None, + description: None, + organizer: None, + timers: default_timer_selection(), + primary_timer: None, + roster: Vec::new(), + classes: Vec::new(), + classes_membership: Vec::new(), + rounds: Vec::new(), + }, + state: practice_state, + }, + ); + + if let Some(dir) = &data_dir { + std::fs::create_dir_all(dir).map_err(|e| { + RegistryError::io(format!("could not create data dir {}: {e}", dir.display())) + })?; + // Reload every previously-created event (issue #111): scan the data dir for the + // per-event `.sqlite` files and restore each event's `EventMeta` + its log into + // the registry. Without this the registry only ever seeded Practice on boot, so + // created events vanished on a Director restart (and the persisted active-event id + // degraded to the picker because its event wasn't loaded). Practice stays the + // built-in in-memory event, seeded above and never overwritten here. + restore_persisted_events(dir, &tokens, &mut events); + } + + // Restore the persisted active event (issue #90) on boot: read `/active-event` + // if present and it still names a known event. A missing file, an unreadable one, or a + // stale id (the event no longer exists) all degrade to `None` — the picker — rather than + // failing to boot. + let active_event = data_dir + .as_deref() + .and_then(read_persisted_active_event) + .filter(|id| events.contains_key(id)); + + Ok(Self { + inner: Arc::new(RwLock::new(Registry { + events, + tokens, + timers, + pilots, + classes, + data_dir, + active_event, + })), + }) + } + + /// The Director-wide application-level **timer registry** (issue #73) — the app-level + /// authority the Timers API mutates and the per-event source bridge resolves selected timers + /// through. Cloning shares the one registry. + pub fn timers(&self) -> TimerRegistry { + self.read().timers.clone() + } + + /// The Director-wide application-level **pilot directory** (issue #74) — the app-level + /// authority the Pilots API mutates and the per-event roster references. Cloning shares the one + /// directory. + pub fn pilots(&self) -> PilotDirectory { + self.read().pilots.clone() + } + + /// The Director-wide application-level **class directory** (issue #84) — the app-level authority + /// the Classes API mutates and the per-event class selection references. Cloning shares the one + /// directory. + pub fn classes(&self) -> ClassDirectory { + self.read().classes.clone() + } + + /// The Director's currently-active event's [`EventMeta`] (issue #90), or `None` when no + /// event is active (the picker). The single read every client makes on connect/reload to + /// resume into the selected event. + pub fn active(&self) -> Option { + let reg = self.read(); + reg.active_event + .as_ref() + .and_then(|id| reg.events.get(id)) + .map(|e| e.meta.clone()) + } + + /// Set the Director's active event (issue #90), returning its [`EventMeta`]. Validates the + /// id names a known event, else [`RegistryError`] (the caller maps it to a typed 404). The + /// new active id is **persisted** to `/active-event` (when a data dir is + /// configured) so it survives a Director restart; with no data dir it is held in memory. + pub fn set_active(&self, id: &EventId) -> Result { + let mut reg = self.write(); + let meta = reg + .events + .get(id) + .map(|e| e.meta.clone()) + .ok_or_else(|| RegistryError::not_found(format!("no event with id {:?}", id.0)))?; + reg.active_event = Some(id.clone()); + // Persist best-effort; a write failure is logged-shaped (returned) but the in-memory + // state is already updated so the live session is correct regardless. + if let Some(dir) = reg.data_dir.clone() { + write_persisted_active_event(&dir, id) + .map_err(|e| RegistryError::io(format!("could not persist active event: {e}")))?; + } + Ok(meta) + } + + /// Set an event's **selected timers** (issue #73), returning its updated [`EventMeta`]. + /// + /// Validates the event exists (else a [`RegistryError`] the caller maps to a typed 404). The + /// caller is responsible for validating each id names a known timer (the timer registry is a + /// separate authority); this just records the selection on the event's meta. The selection is + /// in-memory on the [`EventMeta`] (the event's own log/SQLite file holds its facts, not its + /// config) — the source bridge reads it live when a heat goes Running. + pub fn set_timers(&self, id: &EventId, ids: Vec) -> Result { + let mut reg = self.write(); + let event = reg + .events + .get_mut(id) + .ok_or_else(|| RegistryError::not_found(format!("no event with id {:?}", id.0)))?; + event.meta.timers = dedup_preserving_order(ids); + // Drop a now-stale primary (issue #112): if the previously-designated primary is no longer + // in the selection, clear it so [`EventMeta::effective_primary`] falls back to the first + // selected timer rather than pointing at a deselected timer. + if let Some(primary) = &event.meta.primary_timer { + if !event.meta.timers.contains(primary) { + event.meta.primary_timer = None; + } + } + let meta = event.meta.clone(); + // Write the updated meta through to disk (issue #111) so a restart sees the latest + // selection. Best-effort against a configured data dir for a persistent event. + let data_dir = reg.data_dir.clone(); + persist_meta_change(data_dir.as_deref(), &meta)?; + Ok(meta) + } + + /// Set an event's **primary** timer (issue #112), returning its updated [`EventMeta`]. + /// + /// Designates which of the event's selected timers is the **primary** (the rest are + /// alternates); the source bridge feeds only the active source's passes, preferring the primary + /// (see [`EventMeta::effective_primary`]). Validates the event exists (else a [`RegistryError`] + /// the caller maps to a typed 404). Passing `Some(id)` requires that id to be **in the event's + /// current selection** (else a [`RegistryError`] — the caller maps it to a bad-request); passing + /// `None` clears the override (the first selected timer becomes the effective primary). + pub fn set_primary_timer( + &self, + id: &EventId, + primary: Option, + ) -> Result { + let mut reg = self.write(); + let event = reg + .events + .get_mut(id) + .ok_or_else(|| RegistryError::not_found(format!("no event with id {:?}", id.0)))?; + if let Some(primary) = &primary { + if !event.meta.timers.contains(primary) { + return Err(RegistryError::invalid(format!( + "primary timer {:?} is not in the event's selected timers", + primary.0 + ))); + } + } + event.meta.primary_timer = primary; + let meta = event.meta.clone(); + // Write the updated meta through to disk (issue #111) so a restart sees the latest + // primary designation. + let data_dir = reg.data_dir.clone(); + persist_meta_change(data_dir.as_deref(), &meta)?; + Ok(meta) + } + + /// Set an event's **roster** (issue #74), returning its updated [`EventMeta`]. + /// + /// Replaces the event's roster wholesale with `pilot_ids`. Validates the event exists (else a + /// [`RegistryError`] the caller maps to a typed 404); the caller is responsible for validating + /// each id names a directory pilot (the pilot directory is a separate authority). The roster is + /// recorded on the event's [`EventMeta`] and **written through** to the event's SQLite `meta` + /// table (issue #115) so it survives a Director restart. + pub fn set_roster( + &self, + id: &EventId, + pilot_ids: Vec, + ) -> Result { + let mut reg = self.write(); + let event = reg + .events + .get_mut(id) + .ok_or_else(|| RegistryError::not_found(format!("no event with id {:?}", id.0)))?; + event.meta.roster = dedup_preserving_order(pilot_ids); + // Membership is the finer roster × classes join, so a pilot dropped from the roster must + // not linger in any class's membership (#336) — a stale slot would still be seated by + // FillRound/ScheduleHeat, bypassing the roster/membership validation the membership PUT + // enforces. Surviving members keep their slots (and their assigned channels) untouched. + prune_membership_to_roster(&mut event.meta); + let meta = event.meta.clone(); + let data_dir = reg.data_dir.clone(); + persist_meta_change(data_dir.as_deref(), &meta)?; + Ok(meta) + } + + /// Set an event's **class selection** (issue #84), returning its updated [`EventMeta`]. + /// + /// Replaces the event's classes wholesale with `ids`. Validates the event exists (else a + /// [`RegistryError`] the caller maps to a typed 404); the caller is responsible for validating + /// each id names a directory class (the class directory is a separate authority). The selection + /// is recorded on the event's [`EventMeta`] and **written through** to the event's SQLite `meta` + /// table (issue #115) so it survives a Director restart — exactly the roster path. + pub fn set_classes(&self, id: &EventId, ids: Vec) -> Result { + let mut reg = self.write(); + let event = reg + .events + .get_mut(id) + .ok_or_else(|| RegistryError::not_found(format!("no event with id {:?}", id.0)))?; + event.meta.classes = dedup_preserving_order(ids); + // A deselected class's membership goes with it (#336): membership only means anything for + // a class the event runs, and a stale entry would still field pilots if the class were + // reselected later under different assumptions (or be resolved by a round that still + // names it). Memberships of the surviving selection are untouched. + let selected = event.meta.classes.clone(); + event + .meta + .classes_membership + .retain(|m| selected.contains(&m.class)); + let meta = event.meta.clone(); + let data_dir = reg.data_dir.clone(); + persist_meta_change(data_dir.as_deref(), &meta)?; + Ok(meta) + } + + /// Set the **per-class membership** for one class (race redesign Slice 1a), returning the + /// event's updated [`EventMeta`]. + /// + /// Replaces *that class's* slot list wholesale with `pilots` (other classes' memberships are + /// untouched), each [`MemberSlot`] carrying the pilot and its optional fixed channel (Slice 7a). + /// An empty `pilots` removes the class's membership entry entirely (no empty entries are + /// persisted). Validates the event exists (else a [`RegistryError`] the caller maps to a typed + /// 404); the caller is responsible for validating that `class` names a directory class, each + /// pilot id names a directory pilot, and each set channel is in the event's primary timer's + /// available channels (the class/pilot/timer registries are separate authorities). The membership + /// is recorded on the event's [`EventMeta`] and **written through** + /// to the event's SQLite `meta` table (issue #115) so it survives a Director restart — exactly + /// the roster/classes path. + pub fn set_class_membership( + &self, + id: &EventId, + class: ClassId, + pilots: Vec, + ) -> Result { + let mut reg = self.write(); + let event = reg + .events + .get_mut(id) + .ok_or_else(|| RegistryError::not_found(format!("no event with id {:?}", id.0)))?; + // Last-write-wins, set-membership semantics: replace the class's entry, drop it when the + // new list is empty, so re-applying the same membership is idempotent. + event.meta.classes_membership.retain(|m| m.class != class); + if !pilots.is_empty() { + event + .meta + .classes_membership + .push(ClassMembership { class, pilots }); + } + let meta = event.meta.clone(); + let data_dir = reg.data_dir.clone(); + persist_meta_change(data_dir.as_deref(), &meta)?; + Ok(meta) + } + + /// Add a **round** to an event (race redesign Slice 2a), returning the created [`RoundDef`] + /// (with its **generated** [`RoundId`]). + /// + /// The id is auto-generated — a slug of the request's `label` plus a short random suffix + /// (mirroring the event/pilot id-gen) — retried on the (astronomically unlikely) collision with + /// an existing round id. Validation (all [`RoundError::Invalid`], mapped to a 400): + /// + /// - each [`classes`](NewRoundReq::classes) entry exists in the class directory **and** is one + /// of the event's selected [`classes`](EventMeta::classes); + /// - the [`format`](NewRoundReq::format) is a known [`FormatRegistry::standard`] name; + /// - on [`SeedingRule::FromRanking`], each `source_rounds` entry names an existing round in this + /// event. + /// + /// An unknown event is a [`RoundError::EventNotFound`] (→ 404). On success the round is appended + /// to [`EventMeta::rounds`] and written through to the event's SQLite `meta` table (issue #115) + /// so it survives a Director restart — exactly the classes/membership path. + pub fn add_round(&self, id: &EventId, req: NewRoundReq) -> Result { + let mut reg = self.write(); + let directory = reg.classes.clone(); + let event = reg + .events + .get_mut(id) + .ok_or_else(|| RoundError::EventNotFound(id.0.clone()))?; + + // The effective win condition (an omitted one stores the inert default) — used both to + // validate the round can end and to build the `RoundDef` below. + let win_condition = req.win_condition.unwrap_or_else(default_win_condition); + // Default the channel mode **by format** when the request omits it (Slice 7a): + // `timed_qual`/`round_robin` → Static, the bracket formats → PerHeat; an explicit + // request value overrides. Resolved *before* validation so the Static/seeding rule can run. + let channel_mode = req + .channel_mode + .unwrap_or_else(|| ChannelMode::default_for_format(&req.format)); + validate_round_fields( + &event.meta, + &directory, + &req.classes, + &req.format, + &req.seeding, + channel_mode, + &win_condition, + req.time_limit_secs, + None, + )?; + validate_round_params(&req.format, &req.params)?; + validate_min_lap(req.min_lap_secs)?; + + // Auto-generate a unique round id within this event: slug(label) + short suffix, retried on + // the (astronomically unlikely) collision so the id is always fresh. + let round_id = loop { + let candidate = RoundId(format!("{}-{}", slugify(&req.label), short_suffix())); + if !event.meta.rounds.iter().any(|r| r.id == candidate) { + break candidate; + } + }; + + let round = RoundDef { + id: round_id, + label: req.label, + classes: req.classes, + format: req.format, + params: req.params, + // The effective win condition computed + validated above (omitted ⇒ inert default). + win_condition, + seeding: req.seeding, + channel_mode, + // Heat-lifecycle Slice 2 configs: omitted request fields take their documented defaults. + staging_timer_secs: req + .staging_timer_secs + .unwrap_or_else(default_staging_timer_secs), + start_procedure: req.start_procedure.unwrap_or_default(), + grace_window: req.grace_window.unwrap_or_else(default_grace_window), + // The protest window (marshaling Slice 5): omitted ⇒ `Off` (manual finalize only). + protest_window: req.protest_window.unwrap_or_default(), + // The optional open-practice duration (open-practice refinement): carried through as-is. + min_lap_secs: req.min_lap_secs.filter(|s| *s > 0), + time_limit_secs: req.time_limit_secs, + }; + event.meta.rounds.push(round.clone()); + let meta = event.meta.clone(); + let data_dir = reg.data_dir.clone(); + persist_meta_change(data_dir.as_deref(), &meta)?; + Ok(round) + } + + /// Replace an existing **round**'s editable fields (race redesign Slice 2a), returning the + /// updated [`RoundDef`]. + /// + /// The round's [`id`](RoundDef::id) is fixed (the path segment); every other field is replaced + /// wholesale with `req`. Same validation as [`add_round`](Self::add_round): unknown event → + /// [`RoundError::EventNotFound`] (404); unknown round id → [`RoundError::RoundNotFound`] (404); + /// bad class / format / dangling seeding source → [`RoundError::Invalid`] (400). A + /// [`SeedingRule::FromRanking`] may not name **this** round as its own source. Written through to + /// disk (issue #115). + /// The **freeze probe** for round config (release-hardening): fold the event's log and + /// report `(has_heats, raced)` for `round_id` — whether ANY heat is tagged with it, and + /// whether any such heat has left `Scheduled` (staged / raced / scored). Scoring re-derives + /// from the round's CURRENT config on every read, so editing a raced round's scoring fields + /// would silently rewrite already-official results — the callers below reject that. + fn round_heat_facts(&self, id: &EventId, round_id: &RoundId) -> (bool, bool) { + let Some(state) = self.resolve(id) else { + return (false, false); + }; + let Ok((events, _cursor)) = state.read() else { + return (false, false); + }; + let mut has_heats = false; + let mut raced = false; + for event in &events { + if let gridfpv_events::Event::HeatScheduled { + heat, + round: Some(r), + .. + } = event + { + if r == round_id { + has_heats = true; + let heat_state = gridfpv_engine::heat::heat_state(&events, heat); + if heat_state.is_some_and(|s| s != gridfpv_engine::heat::HeatState::Scheduled) { + raced = true; + break; + } + } + } + } + (has_heats, raced) + } + + pub fn update_round( + &self, + id: &EventId, + round_id: &RoundId, + req: UpdateRoundReq, + ) -> Result { + // Probe the log BEFORE taking the registry write lock (the log has its own mutex). + let (_has_heats, raced) = self.round_heat_facts(id, round_id); + let mut reg = self.write(); + let directory = reg.classes.clone(); + let event = reg + .events + .get_mut(id) + .ok_or_else(|| RoundError::EventNotFound(id.0.clone()))?; + + let Some(existing) = event + .meta + .rounds + .iter() + .find(|r| &r.id == round_id) + .cloned() + else { + return Err(RoundError::RoundNotFound(round_id.0.clone())); + }; + let win_condition = req.win_condition.unwrap_or_else(default_win_condition); + // As with add: an omitted channel mode defaults by the (new) format; an explicit value + // overrides. The round is replaced wholesale, so the mode is re-derived each update. + // Resolved *before* validation so the Static/seeding rule can run. + let channel_mode = req + .channel_mode + .unwrap_or_else(|| ChannelMode::default_for_format(&req.format)); + validate_round_fields( + &event.meta, + &directory, + &req.classes, + &req.format, + &req.seeding, + channel_mode, + &win_condition, + req.time_limit_secs, + Some(round_id), + )?; + validate_round_params(&req.format, &req.params)?; + validate_min_lap(req.min_lap_secs)?; + + // A RACED round's scoring-defining config is FROZEN (user-approved policy): scoring + // re-derives from the round's current config, so editing these would silently re-score + // already-official heats (a config-side bypass of the Final lock), and re-seeding would + // rewrite a bracket chain. Still editable on a raced round: label, staging timer, start + // procedure, grace window, protest window, time limit — and the `rounds` param (heats + // per pilot), which only extends future fills. + if raced { + let effective_channel_mode = channel_mode; + let mut frozen: Vec<&str> = Vec::new(); + if req.format != existing.format { + frozen.push("format"); + } + if req.classes != existing.classes { + frozen.push("classes"); + } + if win_condition != existing.win_condition { + frozen.push("win condition"); + } + if req.seeding != existing.seeding { + frozen.push("seeding"); + } + if effective_channel_mode != existing.channel_mode { + frozen.push("channel mode"); + } + // The min-lap floor suppresses passes from the scored chain — editing it would + // silently re-score raced heats, so it freezes with the win condition. + if req.min_lap_secs.filter(|s| *s > 0) != existing.min_lap_secs { + frozen.push("min lap time"); + } + // Params: only `rounds` (heats per pilot) may change once raced. + let differs_beyond_rounds = { + let mut a = req.params.clone(); + let mut b = existing.params.clone(); + a.remove("rounds"); + b.remove("rounds"); + a != b + }; + if differs_beyond_rounds { + frozen.push("format params (other than rounds)"); + } + if !frozen.is_empty() { + return Err(RoundError::Invalid(format!( + "this round has raced heats — its {} can no longer change (label, staging, \ + start procedure, grace, protest window, race time, and the rounds count \ + stay editable)", + frozen.join(", ") + ))); + } + } + + let round = RoundDef { + id: round_id.clone(), + label: req.label, + classes: req.classes, + format: req.format, + params: req.params, + // The effective win condition computed + validated above (omitted ⇒ inert default). + win_condition, + seeding: req.seeding, + channel_mode, + // Heat-lifecycle Slice 2 configs: replaced wholesale, defaulting an omitted field. + staging_timer_secs: req + .staging_timer_secs + .unwrap_or_else(default_staging_timer_secs), + start_procedure: req.start_procedure.unwrap_or_default(), + grace_window: req.grace_window.unwrap_or_else(default_grace_window), + // The protest window (marshaling Slice 5): omitted ⇒ `Off` (manual finalize only). + protest_window: req.protest_window.unwrap_or_default(), + // The min-lap floor (D26): normalized so 0 and omitted are the same OFF. + min_lap_secs: req.min_lap_secs.filter(|s| *s > 0), + // The optional open-practice duration (open-practice refinement): replaced wholesale. + time_limit_secs: req.time_limit_secs, + }; + if let Some(slot) = event.meta.rounds.iter_mut().find(|r| &r.id == round_id) { + *slot = round.clone(); + } + let meta = event.meta.clone(); + let data_dir = reg.data_dir.clone(); + persist_meta_change(data_dir.as_deref(), &meta)?; + Ok(round) + } + + /// Remove a **round** from an event (race redesign Slice 2a), returning the event's updated + /// [`EventMeta`]. + /// + /// Unknown event → [`RoundError::EventNotFound`] (404); unknown round id → + /// [`RoundError::RoundNotFound`] (404). Other rounds that seed from the removed round + /// ([`SeedingRule::FromRanking`]) are **left as-is** (a dangling source is caught the next time + /// that round is edited); pruning is a later-slice concern. Written through to disk (issue #115). + pub fn remove_round(&self, id: &EventId, round_id: &RoundId) -> Result { + // A round with heats in the log cannot be removed: its heats would strand (they resolve + // their name, win condition, and scoring through the round), and a raced round's results + // would lose their scoring config entirely. The log is append-only, so there is nothing + // safe to "cascade" — the RD abandons a misconfigured round by just not filling it. + let (has_heats, _raced) = self.round_heat_facts(id, round_id); + if has_heats { + return Err(RoundError::Invalid( + "this round has scheduled heats — it can no longer be removed (leave it \ + unfilled, or discard its heats and re-use it)" + .to_string(), + )); + } + let mut reg = self.write(); + let event = reg + .events + .get_mut(id) + .ok_or_else(|| RoundError::EventNotFound(id.0.clone()))?; + let before = event.meta.rounds.len(); + event.meta.rounds.retain(|r| &r.id != round_id); + if event.meta.rounds.len() == before { + return Err(RoundError::RoundNotFound(round_id.0.clone())); + } + let meta = event.meta.clone(); + let data_dir = reg.data_dir.clone(); + persist_meta_change(data_dir.as_deref(), &meta)?; + Ok(meta) + } + + /// An event's **rounds** (race redesign Slice 2a), or `None` if no such event. + pub fn rounds_of(&self, id: &EventId) -> Option> { + self.read().events.get(id).map(|e| e.meta.rounds.clone()) + } + + /// Add **one** pilot to an event's roster (issue #74), returning its updated [`EventMeta`]. + /// + /// Idempotent — adding a pilot already on the roster is a no-op (no duplicate). Validates the + /// event exists (else a [`RegistryError`] → 404); the caller validates the pilot id exists in + /// the directory. Writes the updated meta through to disk (issue #115). + pub fn add_to_roster(&self, id: &EventId, pilot: PilotId) -> Result { + let mut reg = self.write(); + let event = reg + .events + .get_mut(id) + .ok_or_else(|| RegistryError::not_found(format!("no event with id {:?}", id.0)))?; + if !event.meta.roster.contains(&pilot) { + event.meta.roster.push(pilot); + } + let meta = event.meta.clone(); + let data_dir = reg.data_dir.clone(); + persist_meta_change(data_dir.as_deref(), &meta)?; + Ok(meta) + } + + /// Remove **one** pilot from an event's roster (issue #74), returning its updated [`EventMeta`]. + /// + /// Idempotent — removing a pilot not on the roster is a no-op. Validates the event exists (else + /// a [`RegistryError`] → 404). Writes the updated meta through to disk (issue #115). + pub fn remove_from_roster( + &self, + id: &EventId, + pilot: &PilotId, + ) -> Result { + let mut reg = self.write(); + let event = reg + .events + .get_mut(id) + .ok_or_else(|| RegistryError::not_found(format!("no event with id {:?}", id.0)))?; + event.meta.roster.retain(|p| p != pilot); + // Same staleness hole as the roster PUT (#336): the removed pilot's membership slots go + // with them, so no class can still seat a pilot who left the event. + prune_membership_to_roster(&mut event.meta); + let meta = event.meta.clone(); + let data_dir = reg.data_dir.clone(); + persist_meta_change(data_dir.as_deref(), &meta)?; + Ok(meta) + } + + /// An event's full [`EventMeta`] (issue #112), or `None` if no such event — the source bridge + /// reads it live to learn the selection *and* the effective primary in one consistent snapshot. + pub fn meta_of(&self, id: &EventId) -> Option { + self.read().events.get(id).map(|e| e.meta.clone()) + } + + /// An event's currently-**selected timer ids** (issue #73), or `None` if no such event. + /// + /// The per-event source bridge reads this live when a heat goes Running to decide which + /// timers to drive (resolving each id through the [`TimerRegistry`]). + pub fn timers_of(&self, id: &EventId) -> Option> { + self.read().events.get(id).map(|e| e.meta.timers.clone()) + } + + /// The shared [`TokenStore`] — the Director mints/pins its RD token through this so the + /// one credential authenticates control on *every* event. + pub fn tokens(&self) -> TokenStore { + self.read().tokens.clone() + } + + /// Resolve an [`EventId`] to that event's [`AppState`], or `None` if no such event. + /// + /// This is the registry's core operation: every per-event route resolves the id here and + /// serves against the returned state's own log. An unknown id is the caller's cue to + /// return a typed 404 (mirroring the `UnknownScope` pattern). + pub fn resolve(&self, id: &EventId) -> Option { + self.read().events.get(id).map(|e| e.state.clone()) + } + + /// The metadata for every event, **Practice first**, then the rest in id order. + /// + /// The order is stable so `GET /events` is deterministic and the console can default to + /// the first (Practice). + pub fn list(&self) -> Vec { + let reg = self.read(); + let mut out = Vec::with_capacity(reg.events.len()); + let practice = EventId(PRACTICE_EVENT_ID.to_string()); + if let Some(p) = reg.events.get(&practice) { + out.push(p.meta.clone()); + } + for (id, ev) in ®.events { + if *id != practice { + out.push(ev.meta.clone()); + } + } + out + } + + /// Create a new persistent event from a [`CreateEventRequest`], returning its [`EventMeta`]. + /// + /// The **id is auto-generated** — a slug of the request's `name` plus a short random + /// suffix — so it is unique and never user-entered (names are display-only). The optional + /// descriptive fields (`date`/`location`/`description`/`organizer`) are stored verbatim on + /// the new event's meta. A file-backed [`SqliteLog`](gridfpv_storage::SqliteLog) is opened + /// under the configured data dir (`/.sqlite`); with no data dir configured the + /// event falls back to an in-memory log so creation still succeeds. The new event shares the + /// registry's token store, so the RD's token controls it immediately. + pub fn create(&self, request: &CreateEventRequest) -> Result { + let name = request.name.as_str(); + let mut reg = self.write(); + + // Auto-generate a unique id: slug + short random suffix, retried on the (astronomically + // unlikely) collision so the id is always fresh and never the reserved `practice`. + let id = loop { + let candidate = EventId(format!("{}-{}", slugify(name), short_suffix())); + if candidate.0 != PRACTICE_EVENT_ID && !reg.events.contains_key(&candidate) { + break candidate; + } + }; + + // Open the event's own log: a SQLite file per event under the data dir, else + // in-memory (no configured storage). + let (state, persistent) = match ®.data_dir { + Some(dir) => { + let path = event_db_path(dir, &id); + let log = SqliteLog::open(&path).map_err(|e| { + RegistryError::io(format!("could not open event log {}: {e}", path.display())) + })?; + (AppState::with_tokens(log, reg.tokens.clone()), true) + } + None => ( + AppState::with_tokens(InMemoryLog::new(), reg.tokens.clone()), + false, + ), + }; + + // Optional descriptive fields are stored verbatim, normalized so a blank string is + // treated as "unset" (so an empty "Add details" field never persists an empty label). + let meta = EventMeta { + id: id.clone(), + name: name.to_string(), + created_at: now_millis(), + persistent, + date: normalize_optional(&request.date), + location: normalize_optional(&request.location), + description: normalize_optional(&request.description), + organizer: normalize_optional(&request.organizer), + timers: default_timer_selection(), + primary_timer: None, + roster: Vec::new(), + classes: Vec::new(), + classes_membership: Vec::new(), + rounds: Vec::new(), + }; + // Persist the freshly-built meta into the event's own SQLite `meta` table (issue + // #111) so a Director restart can restore it. Only for a persistent (file-backed) + // event — an in-memory event has nothing to persist to. + if persistent { + if let Some(dir) = reg.data_dir.clone() { + persist_event_meta(&dir, &meta)?; + } + } + + reg.events.insert( + id, + RegisteredEvent { + meta: meta.clone(), + state, + }, + ); + Ok(meta) + } + + /// **Permanently delete** an event and all of its data (the headline papercut fix). + /// + /// Removes the registry entry, deletes the event's on-disk state (its `.sqlite` log plus + /// the WAL/SHM sidecars under the data dir), and — if it was the Director's active event — + /// clears the active pointer (persisting the cleared pointer so the picker is shown after a + /// restart). The deletion is complete: nothing of the event survives a restart (the boot scan + /// finds no `.sqlite` to restore). + /// + /// The built-in **Practice** event ([`PRACTICE_EVENT_ID`]) cannot be deleted — it is the + /// always-present in-memory scratch event — so an attempt is a [`RegistryError`] the caller + /// maps to a `BadRequest`. An unknown id is a [`RegistryError`] the caller maps to a typed 404. + /// + /// The on-disk file removal is best-effort *after* the in-memory drop: dropping the + /// [`RegisteredEvent`] closes the live SQLite connection (its `AppState` is the only holder), + /// so the files are then free to unlink. A missing file is not an error (idempotent cleanup); + /// a genuine unlink failure is surfaced as a [`RegistryError`] so the caller can report it. + pub fn delete(&self, id: &EventId) -> Result<(), RegistryError> { + let mut reg = self.write(); + + if id.0 == PRACTICE_EVENT_ID { + return Err(RegistryError::invalid( + "the built-in Practice event cannot be deleted".to_string(), + )); + } + // Drop the in-memory entry first; this closes the event's own SQLite connection (its + // `AppState` is the sole holder) so the on-disk files are unlocked for removal below. + let removed = reg.events.remove(id); + if removed.is_none() { + return Err(RegistryError::not_found(format!( + "no event with id {:?}", + id.0 + ))); + } + drop(removed); + + // If it was the active event, clear the pointer (and persist the cleared state) so a + // reload/restart lands on the picker rather than dangling at a now-gone event. + if reg.active_event.as_ref() == Some(id) { + reg.active_event = None; + if let Some(dir) = reg.data_dir.clone() { + // Best-effort: removing the pointer file degrades a stale read to `None` anyway. + let _ = std::fs::remove_file(active_event_path(&dir)); + } + } + + // Permanently remove the event's persisted state: the `.sqlite` log plus its WAL/SHM + // sidecars. A missing file is fine (idempotent); a real unlink error is reported. + if let Some(dir) = reg.data_dir.clone() { + remove_event_files(&dir, id)?; + } + Ok(()) + } + + fn read(&self) -> std::sync::RwLockReadGuard<'_, Registry> { + self.inner.read().expect("event registry lock poisoned") + } + + fn write(&self) -> std::sync::RwLockWriteGuard<'_, Registry> { + self.inner.write().expect("event registry lock poisoned") + } +} + +/// The SQLite file an event's log lives in under `dir`: `/.sqlite`. +fn event_db_path(dir: &Path, id: &EventId) -> PathBuf { + dir.join(format!("{}{}", id.0, EVENT_DB_SUFFIX)) +} + +/// Permanently remove an event's on-disk state under `dir`: its `.sqlite` log and the +/// WAL/SHM sidecars SQLite leaves alongside it in WAL journal mode (`.sqlite-wal`, +/// `.sqlite-shm`). Each removal is best-effort against a *missing* file (already gone is +/// success — the cleanup is idempotent), but a genuine unlink failure (e.g. a permission error) +/// on the main log file is surfaced as a [`RegistryError`] so a partial delete is not silent. +fn remove_event_files(dir: &Path, id: &EventId) -> Result<(), RegistryError> { + let main = event_db_path(dir, id); + // The WAL/SHM sidecars share the main path with a suffix appended to the full file name. + let wal = dir.join(format!("{}{}-wal", id.0, EVENT_DB_SUFFIX)); + let shm = dir.join(format!("{}{}-shm", id.0, EVENT_DB_SUFFIX)); + // Sidecars are pure cache/journal — a removal failure there is not fatal (SQLite recreates + // them), so they are unlinked best-effort. The main log file is the durable state; a real + // failure to remove it (not just "already absent") is reported. + let _ = std::fs::remove_file(&wal); + let _ = std::fs::remove_file(&shm); + match std::fs::remove_file(&main) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(RegistryError::io(format!( + "could not delete event log {}: {e}", + main.display() + ))), + } +} + +/// Persist an event's [`EventMeta`] into its own SQLite file's sidecar `meta` table (issue +/// #111), so a Director restart can restore it. Opens the event's `/.sqlite` (WAL +/// allows this alongside the live `AppState` connection), serialises the meta to JSON, and +/// upserts it under [`EVENT_META_KEY`]. +fn persist_event_meta(dir: &Path, meta: &EventMeta) -> Result<(), RegistryError> { + let path = event_db_path(dir, &meta.id); + let log = SqliteLog::open(&path).map_err(|e| { + RegistryError::io(format!( + "could not open event log {} to persist meta: {e}", + path.display() + )) + })?; + let json = serde_json::to_string(meta) + .map_err(|e| RegistryError::io(format!("could not serialise event meta: {e}")))?; + log.set_meta(EVENT_META_KEY, &json) + .map_err(|e| RegistryError::io(format!("could not persist event meta: {e}")))?; + Ok(()) +} + +/// Write an updated [`EventMeta`] through to its SQLite file when the event is persistent and +/// a data dir is configured (issue #111) — the shared tail of every meta mutation +/// (`set_timers`/`set_primary_timer`/…). A non-persistent event (in-memory, no data dir) is a +/// no-op: it has nothing to persist to and is gone on restart by design (Practice). +fn persist_meta_change(data_dir: Option<&Path>, meta: &EventMeta) -> Result<(), RegistryError> { + match data_dir { + Some(dir) if meta.persistent => persist_event_meta(dir, meta), + _ => Ok(()), + } +} + +/// Restore every persisted event in `dir` into `events` on boot (issue #111). +/// +/// Scans `` for `*.sqlite` files (each a created event's own log), and for each one opens +/// the log, reads its [`EventMeta`] back from the sidecar `meta` table, and rebuilds a +/// [`RegisteredEvent`] over that same on-disk log — so created events (and their metadata) +/// survive a Director restart. An entry that cannot be opened, has no persisted meta, or whose +/// meta cannot be parsed is **skipped** (logged-shaped, not fatal) so one bad file never blocks +/// boot. The reserved `practice` id is never produced here (Practice is the in-memory built-in, +/// seeded separately); a stray `practice.sqlite` is ignored so it can't shadow it. +fn restore_persisted_events( + dir: &Path, + tokens: &TokenStore, + events: &mut BTreeMap, +) { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + // No data dir yet (first boot) — nothing to restore. + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + // Only event log files: a `.sqlite`. Derive the id from the stem and skip + // anything else (the `active-event` pointer, `timers.json`, WAL/SHM sidecars). + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let Some(stem) = name.strip_suffix(EVENT_DB_SUFFIX) else { + continue; + }; + // Never let a stray `practice.sqlite` shadow the built-in in-memory Practice event. + if stem == PRACTICE_EVENT_ID || stem.is_empty() { + continue; + } + let id = EventId(stem.to_string()); + + // Open the event's own log and read its persisted meta back. + let log = match SqliteLog::open(&path) { + Ok(log) => log, + Err(e) => { + // An unreadable file — skip, don't fail boot, but make it LOUD: a silent skip here + // would vanish the event with no trace (release-hardening P1-6). + eprintln!( + "WARNING: skipping event log {} on boot: could not open it: {e}", + path.display() + ); + continue; + } + }; + let meta = match log.get_meta(EVENT_META_KEY) { + Ok(Some(json)) => match serde_json::from_str::(&json) { + Ok(meta) => meta, + Err(e) => { + // Unparseable meta — skip, but LOUDLY. A non-additive `EventMeta` change would + // make EVERY prior event fail to parse and silently disappear on upgrade; this + // log line is the only signal that happened (release-hardening P1-6). + eprintln!( + "ERROR: skipping event {:?} (log {}): its persisted meta could not be \ + parsed — a non-additive EventMeta change can do this: {e}", + stem, + path.display() + ); + continue; + } + }, + // No persisted meta (a pre-#111 file, or a half-written create) — skip rather + // than fabricate a name; without meta the event isn't safely reconstructable. + Ok(None) => continue, + Err(e) => { + eprintln!( + "WARNING: skipping event {:?} (log {}): could not read its persisted meta: {e}", + stem, + path.display() + ); + continue; + } + }; + + let state = AppState::with_tokens(log, tokens.clone()); + events.insert(id, RegisteredEvent { meta, state }); + } +} + +/// The file the active-event id is persisted to under `dir` (issue #90). +fn active_event_path(dir: &Path) -> PathBuf { + dir.join(ACTIVE_EVENT_FILE) +} + +/// Read the persisted active-event id from `/active-event`, or `None` if the file is +/// absent/unreadable/blank. The id is validated against the live event set by the caller, so a +/// stale id here is harmless. The file holds just the id (trimmed). +fn read_persisted_active_event(dir: &Path) -> Option { + let raw = std::fs::read_to_string(active_event_path(dir)).ok()?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(EventId(trimmed.to_string())) + } +} + +/// Persist the active-event id to `/active-event` (issue #90), overwriting any prior value. +fn write_persisted_active_event(dir: &Path, id: &EventId) -> std::io::Result<()> { + std::fs::write(active_event_path(dir), &id.0) +} + +/// An error mutating the event registry (a missing event, an invalid request, or a storage +/// failure). Carries a [`RegistryErrorKind`] so the HTTP layer can map an *unknown id* to `404`, a +/// *bad request* to `400`, and an *I/O / persistence* failure to `500` — mirroring [`PilotError`]. +/// +/// This matters because the in-memory state is mutated **before** the write-through: a persistence +/// failure must surface as a `500`, not a `404`/`400`, so the caller knows the change did not durably +/// land (issue: release-hardening P1-7). +#[derive(Debug, Clone)] +pub struct RegistryError { + /// What kind of failure this is (drives the HTTP status the handler picks). + pub kind: RegistryErrorKind, + /// A human-readable message. + pub message: String, +} + +/// The class of a [`RegistryError`], so a handler can pick the right status. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegistryErrorKind { + /// The addressed event/entity does not exist → 404. + NotFound, + /// A bad request value (e.g. a primary timer not in the selection) → 400. + Invalid, + /// A server-side storage / persistence failure → 500. + Io, +} + +impl RegistryError { + /// An unknown-id error (HTTP 404). + pub fn not_found(message: impl Into) -> Self { + Self { + kind: RegistryErrorKind::NotFound, + message: message.into(), + } + } + + /// A validation / bad-request error (HTTP 400). + pub fn invalid(message: impl Into) -> Self { + Self { + kind: RegistryErrorKind::Invalid, + message: message.into(), + } + } + + /// An I/O / persistence error (HTTP 500). + pub fn io(message: impl Into) -> Self { + Self { + kind: RegistryErrorKind::Io, + message: message.into(), + } + } +} + +impl std::fmt::Display for RegistryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "event registry error: {}", self.message) + } +} + +impl std::error::Error for RegistryError {} + +/// An error adding/updating/removing a **round** (race redesign Slice 2a). +/// +/// Distinguishes a missing event/round (the route maps to a typed **404**) from an invalid round +/// definition — a bad class, an unknown format, or a dangling seeding source — (a **400**). A +/// persistence failure folds into [`Invalid`](Self::Invalid) via the `From` +/// conversion so the write-through path stays a single `?`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RoundError { + /// No event with the given id (the inner `String` is the bad event id) — a typed 404. + EventNotFound(String), + /// No round with the given id in the event (the inner `String` is the bad round id) — a 404. + RoundNotFound(String), + /// The round definition is invalid (bad/unselected class, unknown format, or a dangling + /// [`SeedingRule::FromRanking`] source) — a 400. The message names what was rejected. + Invalid(String), +} + +impl std::fmt::Display for RoundError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RoundError::EventNotFound(id) => write!(f, "no event with id {id:?}"), + RoundError::RoundNotFound(id) => write!(f, "no round with id {id:?}"), + RoundError::Invalid(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for RoundError {} + +impl From for RoundError { + fn from(e: RegistryError) -> Self { + RoundError::Invalid(e.message) + } +} + +/// Validate a round's class selection, format, and seeding against the event and the directories +/// (race redesign Slice 2a) — the shared check the add/update paths run. +/// +/// Returns [`RoundError::Invalid`] when: a `classes` entry is unknown to the directory or is not one +/// of the event's selected [`classes`](EventMeta::classes); `format` is not a +/// [`FormatRegistry::standard`] name; or a [`SeedingRule::FromRanking`]'s `source_rounds` is empty +/// or names a round that does not exist in this event (excluding `editing` — a round may not seed +/// from itself). +/// The maximum nesting depth a [`SeedingRule`] may reach — the `Combine`-within-`Combine` cap +/// checked at add/update (and mirrored at fill time by the round engine's depth guard, which also +/// bounds cross-round seeding cycles). A real multi-main composes only a couple of levels deep; the +/// cap rejects pathological / malicious nesting before it can be stored. +pub(crate) const MAX_SEEDING_DEPTH: usize = 8; + +/// Walk a [`SeedingRule`] collecting every **source round** it names into `acc`, recursing into +/// [`Combine`](SeedingRule::Combine) sub-sources, while enforcing the per-rule structural +/// invariants and the [`MAX_SEEDING_DEPTH`] nesting cap (race redesign multi-main). +/// +/// Pushes the sources of [`FromRanking`](SeedingRule::FromRanking), +/// [`FromRankingRange`](SeedingRule::FromRankingRange) and +/// [`FromHeatWinners`](SeedingRule::FromHeatWinners); ignores [`FromRoster`](SeedingRule::FromRoster) +/// / [`AllChannels`](SeedingRule::AllChannels) (no source rounds). Rejects: a `FromRanking` / +/// `FromRankingRange` with an empty source list, a `FromRankingRange` whose `take` is `0`, an empty +/// `Combine.sources`, and nesting deeper than [`MAX_SEEDING_DEPTH`]. The caller validates the +/// collected source rounds (existence + no self-seed) afterwards. +fn collect_source_rounds<'a>( + seeding: &'a SeedingRule, + acc: &mut Vec<&'a RoundId>, + depth: usize, +) -> Result<(), RoundError> { + if depth > MAX_SEEDING_DEPTH { + return Err(RoundError::Invalid("seeding nesting too deep".to_string())); + } + match seeding { + SeedingRule::FromRanking { + source_rounds, + top_n, + } => { + if source_rounds.is_empty() { + return Err(RoundError::Invalid( + "FromRanking seeding must name at least one source round".to_string(), + )); + } + if *top_n == 0 { + return Err(RoundError::Invalid( + "FromRanking top_n must be > 0".to_string(), + )); + } + acc.extend(source_rounds.iter()); + } + SeedingRule::FromRankingRange { + source_rounds, + take, + .. + } => { + if source_rounds.is_empty() { + return Err(RoundError::Invalid( + "FromRankingRange seeding must name at least one source round".to_string(), + )); + } + if *take == 0 { + return Err(RoundError::Invalid( + "FromRankingRange take must be > 0".to_string(), + )); + } + acc.extend(source_rounds.iter()); + } + SeedingRule::FromHeatWinners { source_round } => acc.push(source_round), + SeedingRule::Combine { sources } => { + if sources.is_empty() { + return Err(RoundError::Invalid( + "Combine sources must be non-empty".to_string(), + )); + } + for sub in sources { + collect_source_rounds(sub, acc, depth + 1)?; + } + } + SeedingRule::FromRoster | SeedingRule::AllChannels { .. } => {} + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn validate_round_fields( + meta: &EventMeta, + directory: &ClassDirectory, + classes: &[ClassId], + format: &str, + seeding: &SeedingRule, + channel_mode: ChannelMode, + win_condition: &WinCondition, + time_limit_secs: Option, + editing: Option<&RoundId>, +) -> Result<(), RoundError> { + // A `Static` round (time-trial / qualifying, GQ-style) forms its raced field straight from class + // membership via the channel-balanced builder, but `round_ranking`/standings rank the + // *seeding-resolved* field. Those only agree when seeding is the identity `FromRoster`; any other + // seeding (creatable only via the raw API — the rounds form pairs Static with FromRoster) would + // race a different field than it ranks. Reject it (release-hardening P1-2). + if channel_mode == ChannelMode::Static && !matches!(seeding, SeedingRule::FromRoster) { + return Err(RoundError::Invalid( + "a Static (time-trial / qualifying) round must use FromRoster seeding; use a \ + PerHeat round for ranking- or bracket-seeded fields" + .to_string(), + )); + } + // A **scored** round's heats must be able to END. `Timed` / `FirstToLaps` self-terminate; `BestLap` + // / `BestConsecutive` only *rank* (they never end a heat), so they need a race time + // (`time_limit_secs`) — without one the heat would run forever. Open practice is exempt: it + // intentionally has no end condition and runs until the RD's `ForceEnd` (so no win condition + no + // time limit is valid there). The rounds form always supplies one; this guards the raw API. + if format != OpenPractice::NAME { + let self_terminates = matches!( + win_condition, + WinCondition::Timed { .. } | WinCondition::FirstToLaps { .. } + ); + if !self_terminates && time_limit_secs.is_none() { + return Err(RoundError::Invalid( + "this win condition only ranks — it does not end a heat; set a race time \ + (time_limit_secs), or use a Timed / First-to-N win condition" + .to_string(), + )); + } + } + // Degenerate end-condition values (raw-API guards; the form clamps these). A zero/negative + // Timed window or a First-to-0 would end every heat the instant it starts (the completion + // clock fires before any pass); a zero time limit likewise. + if let WinCondition::Timed { window_micros } = win_condition { + if *window_micros <= 0 { + return Err(RoundError::Invalid( + "a Timed round's race window must be positive".to_string(), + )); + } + } + if let WinCondition::FirstToLaps { n: 0 } = win_condition { + return Err(RoundError::Invalid( + "a First-to-N round must require at least 1 lap".to_string(), + )); + } + if time_limit_secs == Some(0) { + return Err(RoundError::Invalid( + "the race time (time_limit_secs) must be at least 1 second".to_string(), + )); + } + for class in classes { + if !directory.exists(class) { + return Err(RoundError::Invalid(format!( + "no class with id {:?}", + class.0 + ))); + } + if !meta.classes.contains(class) { + return Err(RoundError::Invalid(format!( + "class {:?} is not selected by this event", + class.0 + ))); + } + } + + if !FormatRegistry::standard().contains(format) { + return Err(RoundError::Invalid(format!("unknown format {format:?}"))); + } + + // The seeding rules that name **source rounds** (the bracket/cut carries) must name rounds + // that exist in this event and may never name the round being edited (a round can't seed from + // itself). `FromRanking` / `FromRankingRange` may name several (issue #51 multi-select) and + // require at least one; `FromHeatWinners` (bracket advancement, #217) names exactly one prior + // level; `Combine` recurses into its sub-sources. The recursive collector also enforces the + // per-rule invariants (`take > 0`, non-empty `Combine`) and the nesting-depth cap as it walks. + let mut source_rounds: Vec<&RoundId> = Vec::new(); + collect_source_rounds(seeding, &mut source_rounds, 0)?; + for source_round in source_rounds { + if Some(source_round) == editing { + return Err(RoundError::Invalid( + "a round cannot seed from itself".to_string(), + )); + } + if !meta.rounds.iter().any(|r| &r.id == source_round) { + return Err(RoundError::Invalid(format!( + "seeding source round {:?} does not exist in this event", + source_round.0 + ))); + } + } + + Ok(()) +} + +/// Validate a round's `params` against `format`'s DECLARED schema (release-hardening): params +/// are stored verbatim, so garbage used to surface only at FILL time — mid-event, at the worst +/// moment. A declared number must parse as a positive whole number, an enum must be one of its +/// options, a bool must be true/false. Undeclared keys pass through untouched (e.g. the points +/// table, which has its own editor). Called from add_round/update_round alongside +/// [`validate_round_fields`]. +/// Validate the min-lap floor (D26): 0/omitted is OFF; anything above 10 minutes is a typo +/// (no track has a 10-minute minimum lap), rejected before it can silently eat every lap. +fn validate_min_lap(min_lap_secs: Option) -> Result<(), RoundError> { + if let Some(secs) = min_lap_secs { + if secs > 600 { + return Err(RoundError::Invalid(format!( + "min lap time {secs}s is out of range (0 = off, up to 600s)" + ))); + } + } + Ok(()) +} + +fn validate_round_params( + format: &str, + params: &BTreeMap, +) -> Result<(), RoundError> { + use gridfpv_engine::format::{FormatRegistry, ParamKind}; + let Some(schema) = FormatRegistry::standard_schemas() + .into_iter() + .find(|s| s.name == format) + else { + return Ok(()); // an unoffered/legacy format validates nothing new + }; + for declared in &schema.params { + let Some(value) = params.get(&declared.key) else { + continue; // absent falls back to the default + }; + match declared.kind { + ParamKind::Number => { + // Zero is meaningful for some knobs (an open-ended `rounds: 0`), so the guard + // is "a whole number", not "positive" — the generators clamp semantics. + if value.trim().parse::().is_err() { + return Err(RoundError::Invalid(format!( + "{} ({}) must be a whole number, got {value:?}", + declared.label, declared.key + ))); + } + } + ParamKind::Enum => { + if !declared.options.iter().any(|o| o == value) { + return Err(RoundError::Invalid(format!( + "{} ({}) must be one of {:?}, got {value:?}", + declared.label, declared.key, declared.options + ))); + } + } + ParamKind::Bool => { + if value != "true" && value != "false" { + return Err(RoundError::Invalid(format!( + "{} ({}) must be true or false, got {value:?}", + declared.label, declared.key + ))); + } + } + } + } + Ok(()) +} + +/// Deduplicate `items` **preserving first-seen order** — a wholesale per-event selection (roster / +/// classes / timers) records each id at most once, so a duplicate in the request never double-counts +/// (a duplicate timer, for instance, would otherwise double-feed the source bridge). +fn dedup_preserving_order(items: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(items.len()); + for item in items { + if !out.contains(&item) { + out.push(item); + } + } + out +} + +/// Drop every [`classes_membership`](EventMeta::classes_membership) slot whose pilot is **not on +/// the roster** (#336) — the shared prune the roster-shrinking mutations (the roster PUT and the +/// per-pilot DELETE) apply so membership never outlives the roster it joins. A membership entry +/// left with no pilots is removed entirely (no empty entries are persisted — the same invariant +/// [`EventRegistry::set_class_membership`] keeps). Surviving slots are untouched, so a remaining +/// member keeps their assigned channel. +fn prune_membership_to_roster(meta: &mut EventMeta) { + let roster = &meta.roster; + for membership in &mut meta.classes_membership { + membership + .pilots + .retain(|slot| roster.contains(&slot.pilot)); + } + meta.classes_membership.retain(|m| !m.pilots.is_empty()); +} + +/// The default per-event timer selection (issue #73): just the built-in **Mock** +/// ([`MOCK_TIMER_ID`]). New events and Practice select it so they run a sim race out of the box. +fn default_timer_selection() -> Vec { + vec![TimerId(MOCK_TIMER_ID.to_string())] +} + +/// The built-in Mock timer's default `laps`/`lap_ms` (issue #73), read from the same env +/// knobs the sim source uses (`GRIDFPV_SIM_LAPS` / `GRIDFPV_SIM_LAP_MS`), falling back to the +/// canonical defaults (5 laps @ 2500ms) when unset/unparseable — so the Mock timer's config +/// matches the env-driven sim exactly. Kept here (not in the app crate) to avoid a dependency +/// cycle; the values mirror `gridfpv_app::source::DEFAULT_SIM_LAPS`/`DEFAULT_SIM_LAP_MS`. +fn sim_defaults() -> (u32, u64) { + let laps = std::env::var("GRIDFPV_SIM_LAPS") + .ok() + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(5); + let lap_ms = std::env::var("GRIDFPV_SIM_LAP_MS") + .ok() + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(2500); + (laps, lap_ms) +} + +/// Current wall-clock time in milliseconds since the Unix epoch (creation timestamps). +fn now_millis() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +/// Trim an optional descriptive field, treating a blank/whitespace-only value as **unset** +/// (`None`) so an empty "Add details" input never persists an empty label. +fn normalize_optional(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +/// Slugify a display name into the id-friendly stem: lowercase, ASCII alphanumerics kept, +/// every run of other characters collapsed to a single `-`, trimmed of leading/trailing +/// dashes. An empty/symbol-only name yields `event` so the id stem is never blank. +fn slugify(name: &str) -> String { + let mut slug = String::new(); + let mut prev_dash = false; + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + prev_dash = false; + } else if !prev_dash { + slug.push('-'); + prev_dash = true; + } + } + let trimmed = slug.trim_matches('-'); + if trimmed.is_empty() { + "event".to_string() + } else { + trimmed.to_string() + } +} + +/// A short random lowercase-alphanumeric suffix that makes an auto-generated id unique even +/// when two events share a name. Drawn from the OS CSPRNG (the same source the auth tokens +/// use) so it is unguessable and collision-resistant. +fn short_suffix() -> String { + const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + let mut bytes = [0u8; 6]; + getrandom::fill(&mut bytes).expect("OS CSPRNG available"); + bytes + .iter() + .map(|b| ALPHABET[(*b as usize) % ALPHABET.len()] as char) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use gridfpv_events::{CompetitorRef, Event, HeatId}; + + /// A name-only create request (the common one-click path) for the tests. + /// Wrap bare pilot ids into channel-less [`MemberSlot`]s — the membership shape the registry now + /// takes (race redesign Slice 7a). + fn slots(pilots: Vec) -> Vec { + pilots.into_iter().map(MemberSlot::new).collect() + } + + fn req(name: &str) -> CreateEventRequest { + CreateEventRequest { + name: name.to_string(), + date: None, + location: None, + description: None, + organizer: None, + } + } + + #[test] + fn practice_is_always_present_and_first() { + let reg = EventRegistry::new(None).unwrap(); + let list = reg.list(); + assert_eq!(list.first().unwrap().id.0, PRACTICE_EVENT_ID); + assert_eq!(list.first().unwrap().name, PRACTICE_EVENT_NAME); + assert!(!list.first().unwrap().persistent); + // Practice resolves to a usable AppState. + assert!(reg.resolve(&EventId(PRACTICE_EVENT_ID.into())).is_some()); + } + + #[test] + fn unknown_event_does_not_resolve() { + let reg = EventRegistry::new(None).unwrap(); + assert!(reg.resolve(&EventId("nope".into())).is_none()); + } + + #[test] + fn create_auto_generates_a_unique_slug_id() { + let reg = EventRegistry::new(None).unwrap(); + let a = reg.create(&req("Spring Cup 2026!")).unwrap(); + let b = reg.create(&req("Spring Cup 2026!")).unwrap(); + // Same name, distinct ids (the random suffix disambiguates); slug is name-derived. + assert!(a.id.0.starts_with("spring-cup-2026-")); + assert!(b.id.0.starts_with("spring-cup-2026-")); + assert_ne!(a.id, b.id); + // Both resolve, and they are listed after Practice. + assert!(reg.resolve(&a.id).is_some()); + let ids: Vec<_> = reg.list().into_iter().map(|m| m.id).collect(); + assert_eq!(ids[0].0, PRACTICE_EVENT_ID); + assert!(ids.contains(&a.id) && ids.contains(&b.id)); + } + + #[test] + fn created_event_log_is_independent_of_practice() { + let reg = EventRegistry::new(None).unwrap(); + let practice = reg.resolve(&EventId(PRACTICE_EVENT_ID.into())).unwrap(); + let created = reg.create(&req("Race Night")).unwrap(); + let created_state = reg.resolve(&created.id).unwrap(); + + // Append a heat into the created event only. + created_state + .append( + Event::HeatScheduled { + heat: HeatId("q-1".into()), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + + // The created event's log has the heat; Practice's log is untouched (per-event dense). + let (created_events, _) = created_state.read().unwrap(); + assert_eq!(created_events.len(), 1); + let (practice_events, _) = practice.read().unwrap(); + assert_eq!(practice_events.len(), 0); + } + + #[test] + fn one_rd_token_controls_every_event() { + let reg = EventRegistry::new(None).unwrap(); + let rd = reg.tokens().issue_rd_token(); + let created = reg.create(&req("Race Night")).unwrap(); + // The shared token store is the same instance behind every event's AppState. + let practice = reg.resolve(&EventId(PRACTICE_EVENT_ID.into())).unwrap(); + let created_state = reg.resolve(&created.id).unwrap(); + assert!(practice.tokens().authenticate_control(Some(&rd)).is_ok()); + assert!( + created_state + .tokens() + .authenticate_control(Some(&rd)) + .is_ok() + ); + } + + #[test] + fn slugify_collapses_and_trims() { + assert_eq!(slugify("Spring Cup 2026!"), "spring-cup-2026"); + assert_eq!(slugify(" weird___name "), "weird-name"); + assert_eq!(slugify("!!!"), "event"); + assert_eq!(slugify(""), "event"); + } + + #[test] + fn active_event_defaults_to_none_then_set_and_read_back() { + let reg = EventRegistry::new(None).unwrap(); + assert!(reg.active().is_none()); + + // Setting to a known event returns its meta and reads back. + let practice = EventId(PRACTICE_EVENT_ID.into()); + let meta = reg.set_active(&practice).unwrap(); + assert_eq!(meta.id, practice); + assert_eq!(reg.active().map(|m| m.id), Some(practice)); + } + + #[test] + fn set_active_rejects_an_unknown_event() { + let reg = EventRegistry::new(None).unwrap(); + assert!(reg.set_active(&EventId("nope".into())).is_err()); + assert!(reg.active().is_none()); + } + + #[test] + fn active_event_persists_across_a_restart_with_a_data_dir() { + let dir = std::env::temp_dir().join(format!("gridfpv-active-test-{}", short_suffix())); + { + let reg = EventRegistry::new(Some(dir.clone())).unwrap(); + let created = reg.create(&req("Persisted")).unwrap(); + reg.set_active(&created.id).unwrap(); + // A fresh registry over the SAME data dir restores the active event — the created + // event's SQLite file (and its persisted meta) is reloaded on boot (issue #111), so + // a created-id active pointer resolves rather than degrading to the picker. + let reopened = EventRegistry::new(Some(dir.clone())).unwrap(); + assert_eq!(reopened.active().map(|m| m.id), Some(created.id.clone())); + + // Persisting Practice (always present) survives the restart. + reg.set_active(&EventId(PRACTICE_EVENT_ID.into())).unwrap(); + let reopened2 = EventRegistry::new(Some(dir.clone())).unwrap(); + assert_eq!( + reopened2.active().map(|m| m.id.0), + Some(PRACTICE_EVENT_ID.to_string()) + ); + } + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn created_events_and_their_metadata_survive_a_restart() { + // The core #111 regression: a created event (with a name, descriptive fields, a timer + // selection, and a primary) must be re-listed with its metadata intact, and its log, after + // the Director restarts over the same data dir. + let dir = std::env::temp_dir().join(format!("gridfpv-reload-test-{}", short_suffix())); + let created_id; + { + let reg = EventRegistry::new(Some(dir.clone())).unwrap(); + let created = reg + .create(&CreateEventRequest { + name: "Spring Cup".to_string(), + date: Some("2026-06-20".to_string()), + location: Some("Main field".to_string()), + description: None, + organizer: Some("GridFPV Club".to_string()), + }) + .unwrap(); + created_id = created.id.clone(); + + // Give it a non-default timer selection + an explicit primary, then a log fact. + let a = TimerId("rh-1".into()); + let b = TimerId(MOCK_TIMER_ID.into()); + reg.set_timers(&created.id, vec![a.clone(), b.clone()]) + .unwrap(); + reg.set_primary_timer(&created.id, Some(a.clone())).unwrap(); + reg.set_active(&created.id).unwrap(); + + let state = reg.resolve(&created.id).unwrap(); + state + .append( + Event::HeatScheduled { + heat: HeatId("q-1".into()), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + } + + // Simulate a Director restart: a brand-new registry over the SAME data dir. + let reopened = EventRegistry::new(Some(dir.clone())).unwrap(); + + // The event is listed again (Practice first, then the created one) with its metadata. + let restored = reopened + .meta_of(&created_id) + .expect("the created event should be reloaded on restart"); + assert_eq!(restored.name, "Spring Cup"); + assert!(restored.persistent); + assert_eq!(restored.date.as_deref(), Some("2026-06-20")); + assert_eq!(restored.location.as_deref(), Some("Main field")); + assert_eq!(restored.organizer.as_deref(), Some("GridFPV Club")); + assert_eq!( + restored.timers, + vec![TimerId("rh-1".into()), TimerId(MOCK_TIMER_ID.into())] + ); + assert_eq!(restored.primary_timer, Some(TimerId("rh-1".into()))); + + // It is in the public list, after Practice. + let ids: Vec<_> = reopened.list().into_iter().map(|m| m.id).collect(); + assert_eq!(ids.first().map(|i| i.0.as_str()), Some(PRACTICE_EVENT_ID)); + assert!(ids.contains(&created_id)); + + // Its log facts survived too. + let state = reopened.resolve(&created_id).unwrap(); + let (events, _) = state.read().unwrap(); + assert_eq!(events.len(), 1); + + // And the active-event pointer restores to it (no longer degrades to the picker). + assert_eq!(reopened.active().map(|m| m.id), Some(created_id)); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn delete_removes_the_event_and_all_its_data_and_survives_restart() { + // The headline papercut: deleting an event must remove the registry entry, its persisted + // SQLite log (+ wal/shm), clear it as the active event, and stay gone after a restart. + let dir = std::env::temp_dir().join(format!("gridfpv-delete-test-{}", short_suffix())); + let created_id; + { + let reg = EventRegistry::new(Some(dir.clone())).unwrap(); + let created = reg.create(&req("Doomed Event")).unwrap(); + created_id = created.id.clone(); + // Give it a log fact and make it the active event so deletion must clear that too. + reg.set_active(&created.id).unwrap(); + reg.resolve(&created.id) + .unwrap() + .append( + Event::HeatScheduled { + heat: HeatId("q-1".into()), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + + // The SQLite file exists on disk before the delete. + let db = event_db_path(&dir, &created_id); + assert!( + db.exists(), + "the event's SQLite file should exist pre-delete" + ); + + // Delete it. + reg.delete(&created_id).unwrap(); + + // Gone from the registry, from the list, and as the active event. + assert!(reg.resolve(&created_id).is_none()); + assert!(!reg.list().iter().any(|m| m.id == created_id)); + assert!( + reg.active().is_none(), + "deleting the active event clears it" + ); + + // And the on-disk state is gone (no orphan log / wal / shm files). + assert!(!db.exists(), "the event's SQLite file is removed"); + assert!(!dir.join(format!("{}.sqlite-wal", created_id.0)).exists()); + assert!(!dir.join(format!("{}.sqlite-shm", created_id.0)).exists()); + } + + // Survives a restart: a fresh registry over the same data dir does not re-list it. + let reopened = EventRegistry::new(Some(dir.clone())).unwrap(); + assert!( + reopened.resolve(&created_id).is_none(), + "a deleted event must not reappear after a restart" + ); + assert!(reopened.active().is_none()); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn delete_rejects_practice_and_an_unknown_event() { + let reg = EventRegistry::new(None).unwrap(); + // Practice (the built-in in-memory event) cannot be deleted. + let practice = EventId(PRACTICE_EVENT_ID.into()); + assert!(reg.delete(&practice).is_err()); + assert!( + reg.resolve(&practice).is_some(), + "Practice survives a delete attempt" + ); + // An unknown id is an error and removes nothing. + assert!(reg.delete(&EventId("no-such-event".into())).is_err()); + } + + #[test] + fn new_events_default_to_an_empty_roster_and_set_add_remove_work() { + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Roster Event")).unwrap(); + assert!(event.roster.is_empty(), "a new event has an empty roster"); + + let a = PilotId("acroace-1".into()); + let b = PilotId("zoom-2".into()); + + // set_roster replaces wholesale. + let meta = reg + .set_roster(&event.id, vec![a.clone(), b.clone()]) + .unwrap(); + assert_eq!(meta.roster, vec![a.clone(), b.clone()]); + + // add is idempotent (no duplicate). + let meta = reg.add_to_roster(&event.id, a.clone()).unwrap(); + assert_eq!(meta.roster, vec![a.clone(), b.clone()]); + + // add a fresh pilot appends. + let c = PilotId("newbie-3".into()); + let meta = reg.add_to_roster(&event.id, c.clone()).unwrap(); + assert_eq!(meta.roster, vec![a.clone(), b.clone(), c.clone()]); + + // remove drops one; removing an absent one is a no-op. + let meta = reg.remove_from_roster(&event.id, &b).unwrap(); + assert_eq!(meta.roster, vec![a.clone(), c.clone()]); + let meta = reg.remove_from_roster(&event.id, &b).unwrap(); + assert_eq!(meta.roster, vec![a.clone(), c.clone()]); + + // unknown event → error. + assert!(reg.set_roster(&EventId("nope".into()), vec![]).is_err()); + assert!( + reg.add_to_roster(&EventId("nope".into()), a.clone()) + .is_err() + ); + assert!(reg.remove_from_roster(&EventId("nope".into()), &a).is_err()); + } + + #[test] + fn an_events_roster_persists_across_a_restart() { + // The #115 meta mechanism must carry the additive roster through a Director restart. + let dir = std::env::temp_dir().join(format!("gridfpv-roster-test-{}", short_suffix())); + let created_id; + { + let reg = EventRegistry::new(Some(dir.clone())).unwrap(); + let created = reg.create(&req("Persisted Roster")).unwrap(); + created_id = created.id.clone(); + reg.set_roster( + &created.id, + vec![PilotId("acroace-1".into()), PilotId("zoom-2".into())], + ) + .unwrap(); + } + let reopened = EventRegistry::new(Some(dir.clone())).unwrap(); + let restored = reopened.meta_of(&created_id).expect("event reloaded"); + assert_eq!( + restored.roster, + vec![PilotId("acroace-1".into()), PilotId("zoom-2".into())] + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn new_events_default_to_an_empty_class_selection_and_set_works() { + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Class Event")).unwrap(); + assert!(event.classes.is_empty(), "a new event selects no classes"); + // Practice also defaults to an empty class selection. + assert!( + reg.meta_of(&EventId(PRACTICE_EVENT_ID.into())) + .unwrap() + .classes + .is_empty() + ); + + let a = ClassId("open-1".into()); + let b = ClassId("spec-2".into()); + // set_classes replaces wholesale. + let meta = reg + .set_classes(&event.id, vec![a.clone(), b.clone()]) + .unwrap(); + assert_eq!(meta.classes, vec![a.clone(), b.clone()]); + + // unknown event → error. + assert!(reg.set_classes(&EventId("nope".into()), vec![]).is_err()); + } + + #[test] + fn an_events_class_selection_persists_across_a_restart() { + // The #115 meta mechanism must carry the additive class selection through a restart. + let dir = std::env::temp_dir().join(format!("gridfpv-classes-reg-{}", short_suffix())); + let created_id; + { + let reg = EventRegistry::new(Some(dir.clone())).unwrap(); + let created = reg.create(&req("Persisted Classes")).unwrap(); + created_id = created.id.clone(); + reg.set_classes( + &created.id, + vec![ClassId("open-1".into()), ClassId("spec-2".into())], + ) + .unwrap(); + } + let reopened = EventRegistry::new(Some(dir.clone())).unwrap(); + let restored = reopened.meta_of(&created_id).expect("event reloaded"); + assert_eq!( + restored.classes, + vec![ClassId("open-1".into()), ClassId("spec-2".into())] + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn new_events_default_to_no_class_membership_and_set_replace_clear_work() { + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Membership Event")).unwrap(); + assert!( + event.classes_membership.is_empty(), + "a new event has no per-class membership" + ); + + let open = ClassId("open-1".into()); + let spec = ClassId("spec-2".into()); + let a = PilotId("acroace-1".into()); + let b = PilotId("zoom-2".into()); + let c = PilotId("newbie-3".into()); + + // Set the Open class's membership. + let meta = reg + .set_class_membership(&event.id, open.clone(), slots(vec![a.clone(), b.clone()])) + .unwrap(); + assert_eq!(meta.classes_membership.len(), 1); + assert_eq!(meta.classes_membership[0].class, open); + assert_eq!( + meta.classes_membership[0].pilots, + slots(vec![a.clone(), b.clone()]) + ); + + // A second class gets its own entry; the first is untouched. + let meta = reg + .set_class_membership(&event.id, spec.clone(), slots(vec![c.clone()])) + .unwrap(); + assert_eq!(meta.classes_membership.len(), 2); + let open_entry = meta + .classes_membership + .iter() + .find(|m| m.class == open) + .unwrap(); + assert_eq!(open_entry.pilots, slots(vec![a.clone(), b.clone()])); + + // Re-setting one class replaces only that class's list (last-write-wins, no duplicate entry). + let meta = reg + .set_class_membership(&event.id, open.clone(), slots(vec![a.clone()])) + .unwrap(); + assert_eq!( + meta.classes_membership + .iter() + .filter(|m| m.class == open) + .count(), + 1 + ); + let open_entry = meta + .classes_membership + .iter() + .find(|m| m.class == open) + .unwrap(); + assert_eq!(open_entry.pilots, slots(vec![a.clone()])); + + // An empty list clears the class's membership entry entirely. + let meta = reg + .set_class_membership(&event.id, open.clone(), vec![]) + .unwrap(); + assert!(meta.classes_membership.iter().all(|m| m.class != open)); + assert_eq!(meta.classes_membership.len(), 1, "only Spec remains"); + + // unknown event → error. + assert!( + reg.set_class_membership(&EventId("nope".into()), open, vec![]) + .is_err() + ); + } + + #[test] + fn legacy_bare_pilot_id_membership_deserializes_as_channelless_slots() { + // A pre-Slice-7a `classes_membership` persisted `pilots` as a bare `["acroace-1", …]` + // array; it must still load (each as a channel-less MemberSlot), and re-serialize in the + // canonical slot form — so an old event round-trips through restart. + let legacy = r#"{"class":"open-1","pilots":["acroace-1","zoom-2"]}"#; + let parsed: ClassMembership = serde_json::from_str(legacy).unwrap(); + assert_eq!( + parsed.pilots, + vec![ + MemberSlot::new(PilotId("acroace-1".into())), + MemberSlot::new(PilotId("zoom-2".into())), + ] + ); + + // A mixed array (a bare id + a full slot with a channel) also loads. + let mixed = + r#"{"class":"open-1","pilots":["acroace-1",{"pilot":"zoom-2","channel":5658}]}"#; + let parsed: ClassMembership = serde_json::from_str(mixed).unwrap(); + assert_eq!(parsed.pilots[0].channel, None); + assert_eq!(parsed.pilots[1].channel, Some(5658)); + + // Canonical re-serialization is the slot form; it round-trips back to the same value. + let json = serde_json::to_string(&parsed).unwrap(); + let again: ClassMembership = serde_json::from_str(&json).unwrap(); + assert_eq!(again, parsed); + } + + #[test] + fn member_channels_persist_across_a_restart() { + // A membership carrying per-pilot channels round-trips through the #115 meta mechanism. + let dir = std::env::temp_dir().join(format!("gridfpv-member-chan-{}", short_suffix())); + let created_id; + { + let reg = EventRegistry::new(Some(dir.clone())).unwrap(); + let created = reg.create(&req("Persisted Channels")).unwrap(); + created_id = created.id.clone(); + reg.set_class_membership( + &created.id, + ClassId("open-1".into()), + vec![ + MemberSlot { + pilot: PilotId("acroace-1".into()), + channel: Some(5658), + }, + MemberSlot { + pilot: PilotId("zoom-2".into()), + channel: Some(5695), + }, + ], + ) + .unwrap(); + } + let reopened = EventRegistry::new(Some(dir.clone())).unwrap(); + let restored = reopened.meta_of(&created_id).expect("event reloaded"); + assert_eq!(restored.classes_membership[0].pilots[0].channel, Some(5658)); + assert_eq!(restored.classes_membership[0].pilots[1].channel, Some(5695)); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn class_membership_persists_across_a_restart() { + // The #115 meta mechanism must carry the additive per-class membership through a restart. + let dir = std::env::temp_dir().join(format!("gridfpv-membership-{}", short_suffix())); + let created_id; + { + let reg = EventRegistry::new(Some(dir.clone())).unwrap(); + let created = reg.create(&req("Persisted Membership")).unwrap(); + created_id = created.id.clone(); + reg.set_class_membership( + &created.id, + ClassId("open-1".into()), + slots(vec![PilotId("acroace-1".into()), PilotId("zoom-2".into())]), + ) + .unwrap(); + } + let reopened = EventRegistry::new(Some(dir.clone())).unwrap(); + let restored = reopened.meta_of(&created_id).expect("event reloaded"); + assert_eq!(restored.classes_membership.len(), 1); + assert_eq!( + restored.classes_membership[0].class, + ClassId("open-1".into()) + ); + assert_eq!( + restored.classes_membership[0].pilots, + slots(vec![PilotId("acroace-1".into()), PilotId("zoom-2".into())]) + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn create_persists_a_file_per_event_when_a_data_dir_is_set() { + let dir = std::env::temp_dir().join(format!("gridfpv-reg-test-{}", short_suffix())); + let reg = EventRegistry::new(Some(dir.clone())).unwrap(); + let created = reg.create(&req("Persisted")).unwrap(); + assert!(created.persistent); + let path = event_db_path(&dir, &created.id); + assert!(path.exists(), "an event DB file should be created"); + std::fs::remove_dir_all(&dir).ok(); + } + + // --- Rounds (race redesign Slice 2a) ------------------------------------ + + /// Seed a directory class named `name` and return its generated [`ClassId`]. + fn seed_class(reg: &EventRegistry, name: &str) -> ClassId { + reg.classes() + .create(&crate::classes::CreateClassRequest { + name: name.to_string(), + source: Default::default(), + reference: None, + description: None, + }) + .unwrap() + .id + } + + /// A minimal [`NewRoundReq`]: a `FromRoster` `timed_qual` round over `classes`. + fn round_req(label: &str, classes: Vec) -> NewRoundReq { + NewRoundReq { + label: label.to_string(), + classes, + format: "timed_qual".to_string(), + params: BTreeMap::new(), + win_condition: Some(WinCondition::BestLap), + seeding: SeedingRule::FromRoster, + // Best-lap only ranks, so a scored round needs a race time to end (validation). + time_limit_secs: Some(60), + channel_mode: None, + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + } + } + + #[test] + fn min_lap_is_normalized_validated_and_frozen_once_raced() { + use gridfpv_events::{CompetitorRef, Event, HeatId, HeatTransition}; + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Floor Event")).unwrap(); + let open = seed_class(®, "Open"); + reg.set_classes(&event.id, vec![open.clone()]).unwrap(); + + // 0 normalizes to OFF (None) — omitted and zero mean the same on the wire. + let mut zero = round_req("Zeroed", vec![open.clone()]); + zero.min_lap_secs = Some(0); + let round = reg.add_round(&event.id, zero).unwrap(); + assert_eq!(round.min_lap_secs, None); + + // Out-of-range is rejected (a >10-minute floor eats every lap — a typo). + let mut typo = round_req("Typo", vec![open.clone()]); + typo.min_lap_secs = Some(601); + assert!(reg.add_round(&event.id, typo).is_err()); + + // A real floor sticks… + let mut real = round_req("Floored", vec![open.clone()]); + real.min_lap_secs = Some(5); + let round = reg.add_round(&event.id, real).unwrap(); + assert_eq!(round.min_lap_secs, Some(5)); + + // …and FREEZES once the round has raced (editing it re-scores official heats). + let state = reg.resolve(&event.id).unwrap(); + state + .append( + Event::HeatScheduled { + heat: HeatId("q-1".into()), + lineup: vec![CompetitorRef("A".into())], + class: Some(open.clone()), + round: Some(round.id.clone()), + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + state + .append( + Event::HeatStateChanged { + heat: HeatId("q-1".into()), + transition: HeatTransition::Running, + }, + None, + ) + .unwrap(); + let frozen_req = UpdateRoundReq { + label: round.label.clone(), + classes: round.classes.clone(), + format: round.format.clone(), + params: round.params.clone(), + win_condition: Some(round.win_condition), + seeding: round.seeding.clone(), + time_limit_secs: round.time_limit_secs, + channel_mode: Some(round.channel_mode), + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: Some(10), // was 5 — a scoring change on a raced round + }; + let err = reg + .update_round(&event.id, &round.id, frozen_req) + .unwrap_err(); + assert!( + format!("{err:?}").contains("min lap time"), + "expected the min-lap freeze, got {err:?}" + ); + } + + #[test] + fn add_round_generates_an_id_and_appends() { + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Rounds Event")).unwrap(); + let open = seed_class(®, "Open"); + reg.set_classes(&event.id, vec![open.clone()]).unwrap(); + + let round = reg + .add_round(&event.id, round_req("Qualifying R1", vec![open.clone()])) + .unwrap(); + // The id is generated from the label slug + a suffix (not user-entered, never empty). + assert!( + round.id.0.starts_with("qualifying-r1-"), + "got {:?}", + round.id + ); + assert_eq!(round.label, "Qualifying R1"); + assert_eq!(round.classes, vec![open.clone()]); + assert_eq!(round.format, "timed_qual"); + assert_eq!(round.seeding, SeedingRule::FromRoster); + + // It is appended to the event's rounds list. + let rounds = reg.rounds_of(&event.id).unwrap(); + assert_eq!(rounds.len(), 1); + assert_eq!(rounds[0].id, round.id); + + // A second round with the same label gets a distinct id. + let round2 = reg + .add_round(&event.id, round_req("Qualifying R1", vec![open])) + .unwrap(); + assert_ne!(round.id, round2.id); + assert_eq!(reg.rounds_of(&event.id).unwrap().len(), 2); + } + + #[test] + fn open_practice_round_saves_with_no_win_condition_and_a_time_limit() { + // Open-practice refinement: an open-practice round needs **no win condition** (the request + // omits it — `win_condition: None`) and carries an optional `time_limit_secs`. The round + // saves: the inert default win condition is stored (never consulted), the seeding is + // AllChannels, and the time limit round-trips. + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Practice Event")).unwrap(); + + let round = reg + .add_round( + &event.id, + NewRoundReq { + label: "Open Practice".into(), + classes: vec![], + format: "open_practice".into(), + params: BTreeMap::new(), + // No win condition — the form is not forced to supply one for open practice. + win_condition: None, + seeding: SeedingRule::AllChannels { + channels: vec![0, 1, 2], + }, + time_limit_secs: Some(3600), + channel_mode: None, + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }, + ) + .expect("an open-practice round with no win condition saves"); + + // The inert default win condition is stored (BestLap), never consulted for open practice. + assert_eq!(round.win_condition, default_win_condition()); + assert_eq!(round.time_limit_secs, Some(3600)); + assert!(matches!(round.seeding, SeedingRule::AllChannels { .. })); + + // It round-trips through the event meta with the time limit intact. + let rounds = reg.rounds_of(&event.id).unwrap(); + assert_eq!(rounds.len(), 1); + assert_eq!(rounds[0].time_limit_secs, Some(3600)); + } + + #[test] + fn round_channel_mode_defaults_by_format_and_is_overridable() { + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Modes Event")).unwrap(); + let open = seed_class(®, "Open"); + reg.set_classes(&event.id, vec![open.clone()]).unwrap(); + + // timed_qual → Static; every other registered format → PerHeat (RD-overridable). Only + // registered formats are exercised here (add_round validates the format), so the carved-out + // bracket formats are gone — zippyq stands in for the `_ => PerHeat` default. + let cases = [ + ("timed_qual", ChannelMode::Static), + ("zippyq", ChannelMode::PerHeat), + ]; + for (format, expected) in cases { + assert_eq!(ChannelMode::default_for_format(format), expected); + let mut req = round_req(format, vec![open.clone()]); + req.format = format.to_string(); + let round = reg.add_round(&event.id, req).unwrap(); + assert_eq!( + round.channel_mode, expected, + "{format} should default to {expected:?}" + ); + } + + // An explicit channel_mode overrides the format default (force a qual round per-heat). + let mut req = round_req("timed_qual", vec![open]); + req.channel_mode = Some(ChannelMode::PerHeat); + let round = reg.add_round(&event.id, req).unwrap(); + assert_eq!(round.channel_mode, ChannelMode::PerHeat); + } + + #[test] + fn a_raced_round_freezes_its_scoring_config_but_not_the_race_day_knobs() { + use gridfpv_events::{CompetitorRef, Event, HeatId, HeatTransition}; + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Freeze Event")).unwrap(); + let open = seed_class(®, "Open"); + reg.set_classes(&event.id, vec![open.clone()]).unwrap(); + let round = reg + .add_round(&event.id, round_req("Qual", vec![open.clone()])) + .unwrap(); + + // Race a heat under the round (Scheduled -> Running in the event's log). + let state = reg.resolve(&event.id).unwrap(); + state + .append( + Event::HeatScheduled { + heat: HeatId("q-1".into()), + lineup: vec![CompetitorRef("A".into())], + class: Some(open.clone()), + round: Some(round.id.clone()), + frequencies: vec![], + label: None, + }, + None, + ) + .unwrap(); + state + .append( + Event::HeatStateChanged { + heat: HeatId("q-1".into()), + transition: HeatTransition::Running, + }, + None, + ) + .unwrap(); + + let base = |label: &str| UpdateRoundReq { + label: label.to_string(), + classes: round.classes.clone(), + format: round.format.clone(), + params: round.params.clone(), + win_condition: Some(round.win_condition), + seeding: round.seeding.clone(), + time_limit_secs: round.time_limit_secs, + channel_mode: Some(round.channel_mode), + staging_timer_secs: Some(45), + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }; + + // Race-day knobs (label / staging / etc.) and the `rounds` param stay editable. + let mut ok_req = base("Qualifying (renamed)"); + ok_req.params.insert("rounds".to_string(), "4".to_string()); + let updated = reg.update_round(&event.id, &round.id, ok_req).unwrap(); + assert_eq!(updated.label, "Qualifying (renamed)"); + assert_eq!(updated.params.get("rounds"), Some(&"4".to_string())); + + // The scoring-defining fields are FROZEN: a changed win condition is rejected. + let mut frozen_req = base("Qual"); + frozen_req.win_condition = Some(WinCondition::Timed { + window_micros: 5_000_000, + }); + let err = reg + .update_round(&event.id, &round.id, frozen_req) + .unwrap_err(); + assert!( + format!("{err:?}").contains("raced"), + "expected the raced-round freeze, got {err:?}" + ); + + // ...and a raced round can no longer be removed. + let err = reg.remove_round(&event.id, &round.id).unwrap_err(); + assert!(format!("{err:?}").contains("heats"), "got {err:?}"); + } + + #[test] + fn update_and_remove_a_round() { + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Rounds Event")).unwrap(); + let open = seed_class(®, "Open"); + let spec = seed_class(®, "Spec"); + reg.set_classes(&event.id, vec![open.clone(), spec.clone()]) + .unwrap(); + + let round = reg + .add_round(&event.id, round_req("Practice", vec![open.clone()])) + .unwrap(); + + // Update: replace fields wholesale, id is preserved. + let updated = reg + .update_round( + &event.id, + &round.id, + UpdateRoundReq { + label: "Open Practice".to_string(), + classes: vec![open.clone(), spec.clone()], + format: "head_to_head".to_string(), + params: BTreeMap::from([("advance".to_string(), "2".to_string())]), + win_condition: Some(WinCondition::FirstToLaps { n: 5 }), + seeding: SeedingRule::FromRoster, + time_limit_secs: None, + channel_mode: None, + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }, + ) + .unwrap(); + assert_eq!(updated.id, round.id, "the id is not editable"); + assert_eq!(updated.label, "Open Practice"); + assert_eq!(updated.classes, vec![open, spec]); + assert_eq!(updated.format, "head_to_head"); + assert_eq!(updated.params.get("advance").map(String::as_str), Some("2")); + + // The list reflects the update (still one round). + let rounds = reg.rounds_of(&event.id).unwrap(); + assert_eq!(rounds.len(), 1); + assert_eq!(rounds[0].format, "head_to_head"); + + // Remove: the round is gone; removing it again is a 404. + let meta = reg.remove_round(&event.id, &round.id).unwrap(); + assert!(meta.rounds.is_empty()); + assert!(matches!( + reg.remove_round(&event.id, &round.id), + Err(RoundError::RoundNotFound(_)) + )); + } + + #[test] + fn round_validation_rejects_bad_format_class_and_seeding() { + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Rounds Event")).unwrap(); + let open = seed_class(®, "Open"); + let unselected = seed_class(®, "Spec"); + reg.set_classes(&event.id, vec![open.clone()]).unwrap(); + + // Unknown event → EventNotFound (404). + assert!(matches!( + reg.add_round(&EventId("nope".into()), round_req("R", vec![open.clone()])), + Err(RoundError::EventNotFound(_)) + )); + + // Unknown format → Invalid (400). + let mut bad_format = round_req("R", vec![open.clone()]); + bad_format.format = "no-such-format".to_string(); + assert!(matches!( + reg.add_round(&event.id, bad_format), + Err(RoundError::Invalid(_)) + )); + + // A `*-demo` fixture format is NOT a production format → Invalid. + let mut demo_format = round_req("R", vec![open.clone()]); + demo_format.format = "knockout-demo".to_string(); + assert!(matches!( + reg.add_round(&event.id, demo_format), + Err(RoundError::Invalid(_)) + )); + + // A class not in the directory → Invalid. + assert!(matches!( + reg.add_round(&event.id, round_req("R", vec![ClassId("ghost".into())])), + Err(RoundError::Invalid(_)) + )); + + // A directory class the event does not select → Invalid. + assert!(matches!( + reg.add_round(&event.id, round_req("R", vec![unselected])), + Err(RoundError::Invalid(_)) + )); + + // FromRanking with a dangling source round → Invalid. (Ranking-seeded fields are PerHeat; + // a Static round must use FromRoster — P1-2.) + let mut dangling = round_req("Bracket", vec![open.clone()]); + dangling.channel_mode = Some(ChannelMode::PerHeat); + dangling.seeding = SeedingRule::FromRanking { + source_rounds: vec![RoundId("does-not-exist".into())], + top_n: 4, + }; + assert!(matches!( + reg.add_round(&event.id, dangling), + Err(RoundError::Invalid(_)) + )); + + // FromRanking pointing at an existing round → ok (the #84 carry seam). + let q = reg + .add_round(&event.id, round_req("Qualifying", vec![open.clone()])) + .unwrap(); + let mut bracket = round_req("Bracket", vec![open]); + bracket.channel_mode = Some(ChannelMode::PerHeat); + bracket.seeding = SeedingRule::FromRanking { + source_rounds: vec![q.id.clone()], + top_n: 4, + }; + let bracket = reg.add_round(&event.id, bracket).unwrap(); + assert_eq!( + bracket.seeding, + SeedingRule::FromRanking { + source_rounds: vec![q.id], + top_n: 4 + } + ); + + // A round may not seed from itself (caught on update). + let self_ref = reg.update_round( + &event.id, + &bracket.id, + UpdateRoundReq { + label: bracket.label.clone(), + classes: bracket.classes.clone(), + format: bracket.format.clone(), + params: BTreeMap::new(), + win_condition: Some(bracket.win_condition), + seeding: SeedingRule::FromRanking { + source_rounds: vec![bracket.id.clone()], + top_n: 2, + }, + time_limit_secs: None, + channel_mode: Some(ChannelMode::PerHeat), + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }, + ); + assert!(matches!(self_ref, Err(RoundError::Invalid(_)))); + + // FromHeatWinners (bracket advancement, #217) validates its single source the same way: + // a dangling source is rejected, an existing one is accepted, and self-seeding is caught. + let mut dangling_winners = round_req("Next level", vec![bracket.classes[0].clone()]); + dangling_winners.format = "head_to_head".to_string(); + dangling_winners.seeding = SeedingRule::FromHeatWinners { + source_round: RoundId("does-not-exist".into()), + }; + assert!(matches!( + reg.add_round(&event.id, dangling_winners), + Err(RoundError::Invalid(_)) + )); + + let mut next_level = round_req("Next level", vec![bracket.classes[0].clone()]); + next_level.format = "head_to_head".to_string(); + next_level.seeding = SeedingRule::FromHeatWinners { + source_round: bracket.id.clone(), + }; + let next_level = reg.add_round(&event.id, next_level).unwrap(); + assert_eq!( + next_level.seeding, + SeedingRule::FromHeatWinners { + source_round: bracket.id.clone(), + } + ); + + let self_winners = reg.update_round( + &event.id, + &next_level.id, + UpdateRoundReq { + label: next_level.label.clone(), + classes: next_level.classes.clone(), + format: next_level.format.clone(), + params: BTreeMap::new(), + win_condition: Some(next_level.win_condition), + seeding: SeedingRule::FromHeatWinners { + source_round: next_level.id.clone(), + }, + time_limit_secs: None, + channel_mode: None, + staging_timer_secs: None, + start_procedure: None, + grace_window: None, + protest_window: None, + min_lap_secs: None, + }, + ); + assert!(matches!(self_winners, Err(RoundError::Invalid(_)))); + } + + #[test] + fn round_validation_rejects_bad_multi_main_seeding() { + // The multi-main carries (FromRankingRange / Combine) validate through the shared recursive + // `collect_source_rounds`: a zero-width range, an empty Combine, an over-deep nesting, and a + // dangling source nested inside a Combine are all rejected; a well-formed Combine is accepted. + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Multi-main Event")).unwrap(); + let open = seed_class(®, "Open"); + reg.set_classes(&event.id, vec![open.clone()]).unwrap(); + let q = reg + .add_round(&event.id, round_req("Qualifying", vec![open.clone()])) + .unwrap(); + + // FromRankingRange with take == 0 → Invalid. + let mut zero_take = round_req("C-main", vec![open.clone()]); + zero_take.format = "head_to_head".to_string(); + zero_take.win_condition = Some(WinCondition::FirstToLaps { n: 3 }); + zero_take.seeding = SeedingRule::FromRankingRange { + source_rounds: vec![q.id.clone()], + skip: 12, + take: 0, + }; + assert!(matches!( + reg.add_round(&event.id, zero_take), + Err(RoundError::Invalid(_)) + )); + + // An empty Combine → Invalid. + let mut empty_combine = round_req("Empty", vec![open.clone()]); + empty_combine.format = "head_to_head".to_string(); + empty_combine.win_condition = Some(WinCondition::FirstToLaps { n: 3 }); + empty_combine.seeding = SeedingRule::Combine { sources: vec![] }; + assert!(matches!( + reg.add_round(&event.id, empty_combine), + Err(RoundError::Invalid(_)) + )); + + // A Combine nested past MAX_SEEDING_DEPTH → Invalid (rejected at add, before it can be stored). + let mut seeding = SeedingRule::FromRoster; + for _ in 0..(MAX_SEEDING_DEPTH + 2) { + seeding = SeedingRule::Combine { + sources: vec![seeding], + }; + } + let mut too_deep = round_req("Deep", vec![open.clone()]); + too_deep.format = "head_to_head".to_string(); + too_deep.win_condition = Some(WinCondition::FirstToLaps { n: 3 }); + too_deep.seeding = seeding; + assert!(matches!( + reg.add_round(&event.id, too_deep), + Err(RoundError::Invalid(_)) + )); + + // A dangling source nested inside a Combine is still caught (the collector recurses). + let mut nested_dangling = round_req("Nested dangling", vec![open.clone()]); + nested_dangling.format = "head_to_head".to_string(); + nested_dangling.win_condition = Some(WinCondition::FirstToLaps { n: 3 }); + nested_dangling.seeding = SeedingRule::Combine { + sources: vec![SeedingRule::FromRanking { + source_rounds: vec![RoundId("does-not-exist".into())], + top_n: 2, + }], + }; + assert!(matches!( + reg.add_round(&event.id, nested_dangling), + Err(RoundError::Invalid(_)) + )); + + // A well-formed Combine of two real-source sub-rules is accepted (the B-main shape). + let mut b_main = round_req("B-main", vec![open]); + b_main.format = "head_to_head".to_string(); + b_main.win_condition = Some(WinCondition::FirstToLaps { n: 3 }); + b_main.seeding = SeedingRule::Combine { + sources: vec![ + SeedingRule::FromRankingRange { + source_rounds: vec![q.id.clone()], + skip: 6, + take: 6, + }, + SeedingRule::FromRanking { + source_rounds: vec![q.id.clone()], + top_n: 2, + }, + ], + }; + let b_main = reg.add_round(&event.id, b_main).unwrap(); + assert!(matches!(b_main.seeding, SeedingRule::Combine { .. })); + } + + #[test] + fn static_round_rejects_non_from_roster_seeding() { + // P1-2: a Static round forms its field from class membership but ranks the seeding-resolved + // field — they only agree under FromRoster. Any other seeding on a Static round is rejected. + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Static Event")).unwrap(); + let open = seed_class(®, "Open"); + reg.set_classes(&event.id, vec![open.clone()]).unwrap(); + let q = reg + .add_round(&event.id, round_req("Qualifying", vec![open.clone()])) + .unwrap(); + + // Static + FromRanking → Invalid. + let mut bad = round_req("Bad", vec![open.clone()]); + bad.channel_mode = Some(ChannelMode::Static); + bad.seeding = SeedingRule::FromRanking { + source_rounds: vec![q.id.clone()], + top_n: 4, + }; + assert!(matches!( + reg.add_round(&event.id, bad), + Err(RoundError::Invalid(_)) + )); + + // Static + FromRoster → ok (the time-trial / qualifying default). + let mut ok = round_req("Good", vec![open.clone()]); + ok.channel_mode = Some(ChannelMode::Static); + assert!(reg.add_round(&event.id, ok).is_ok()); + + // The SAME ranking seeding is fine on a PerHeat round (the bracket path). + let mut per_heat = round_req("PerHeat", vec![open]); + per_heat.channel_mode = Some(ChannelMode::PerHeat); + per_heat.seeding = SeedingRule::FromRanking { + source_rounds: vec![q.id], + top_n: 4, + }; + assert!(reg.add_round(&event.id, per_heat).is_ok()); + } + + #[test] + fn from_ranking_rejects_zero_top_n() { + // P2: a `FromRanking { top_n: 0 }` advances nobody — reject it, like FromRankingRange.take. + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Zero Event")).unwrap(); + let open = seed_class(®, "Open"); + reg.set_classes(&event.id, vec![open.clone()]).unwrap(); + let q = reg + .add_round(&event.id, round_req("Qualifying", vec![open.clone()])) + .unwrap(); + + let mut zero = round_req("Bracket", vec![open]); + zero.channel_mode = Some(ChannelMode::PerHeat); + zero.seeding = SeedingRule::FromRanking { + source_rounds: vec![q.id], + top_n: 0, + }; + assert!(matches!( + reg.add_round(&event.id, zero), + Err(RoundError::Invalid(_)) + )); + } + + #[test] + fn wholesale_selections_dedup_preserving_order() { + // P2: roster / classes / timers store each id once, preserving first-seen order — a + // duplicate timer would otherwise double-feed the source bridge. + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Dedup Event")).unwrap(); + + let a = seed_class(®, "A"); + let b = seed_class(®, "B"); + let meta = reg + .set_classes(&event.id, vec![a.clone(), b.clone(), a.clone()]) + .unwrap(); + assert_eq!(meta.classes, vec![a, b]); + + let mock = TimerId(MOCK_TIMER_ID.to_string()); + let meta = reg + .set_timers(&event.id, vec![mock.clone(), mock.clone()]) + .unwrap(); + assert_eq!(meta.timers, vec![mock]); + + let p = reg + .pilots() + .create(&crate::pilots::CreatePilotRequest { + callsign: "P".into(), + ..Default::default() + }) + .unwrap() + .id; + let meta = reg + .set_roster(&event.id, vec![p.clone(), p.clone()]) + .unwrap(); + assert_eq!(meta.roster, vec![p]); + } + + /// Seed a directory pilot by callsign — the membership-prune tests' shorthand. + fn seed_pilot(reg: &EventRegistry, callsign: &str) -> PilotId { + reg.pilots() + .create(&crate::pilots::CreatePilotRequest { + callsign: callsign.to_string(), + ..Default::default() + }) + .unwrap() + .id + } + + #[test] + fn roster_shrink_prunes_the_departed_pilots_membership() { + // #336: a pilot dropped from the roster must not linger in classes_membership — + // a stale slot would still be seated by FillRound, bypassing the membership PUT's + // roster guard. Surviving members keep their slots AND their assigned channels. + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Shrink Event")).unwrap(); + let open = seed_class(®, "Open"); + let (a, b) = (seed_pilot(®, "alpha"), seed_pilot(®, "bravo")); + reg.set_classes(&event.id, vec![open.clone()]).unwrap(); + reg.set_roster(&event.id, vec![a.clone(), b.clone()]) + .unwrap(); + reg.set_class_membership( + &event.id, + open.clone(), + vec![ + MemberSlot { + pilot: a.clone(), + channel: Some(5658), + }, + MemberSlot { + pilot: b.clone(), + channel: Some(5917), + }, + ], + ) + .unwrap(); + + // Shrink the roster to just A: B's membership slot goes with them. + let meta = reg.set_roster(&event.id, vec![a.clone()]).unwrap(); + assert_eq!(meta.classes_membership.len(), 1); + let membership = &meta.classes_membership[0]; + assert_eq!(membership.class, open); + assert_eq!(membership.pilots.len(), 1, "B's slot is pruned"); + assert_eq!(membership.pilots[0].pilot, a); + assert_eq!( + membership.pilots[0].channel, + Some(5658), + "the surviving member keeps their channel" + ); + + // Emptying the roster removes the now-empty membership entry entirely (no empty + // entries are persisted — the set_class_membership invariant). + let meta = reg.set_roster(&event.id, vec![]).unwrap(); + assert!(meta.classes_membership.is_empty()); + } + + #[test] + fn removing_a_roster_pilot_prunes_their_membership() { + // The per-pilot DELETE has the same staleness hole as the roster PUT (#336). + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Remove Event")).unwrap(); + let open = seed_class(®, "Open"); + let (a, b) = (seed_pilot(®, "alpha"), seed_pilot(®, "bravo")); + reg.set_classes(&event.id, vec![open.clone()]).unwrap(); + reg.set_roster(&event.id, vec![a.clone(), b.clone()]) + .unwrap(); + reg.set_class_membership(&event.id, open, slots(vec![a.clone(), b.clone()])) + .unwrap(); + + let meta = reg.remove_from_roster(&event.id, &b).unwrap(); + assert_eq!(meta.roster, vec![a.clone()]); + assert_eq!(meta.classes_membership.len(), 1); + assert_eq!(meta.classes_membership[0].pilots.len(), 1); + assert_eq!(meta.classes_membership[0].pilots[0].pilot, a); + } + + #[test] + fn class_deselect_prunes_its_membership() { + // #336: deselecting a class drops its membership entry — a stale entry would still + // field pilots through any round that names the class. The surviving class's + // membership (channels included) is untouched. + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Deselect Event")).unwrap(); + let open = seed_class(®, "Open"); + let spec = seed_class(®, "Spec"); + let (a, b) = (seed_pilot(®, "alpha"), seed_pilot(®, "bravo")); + reg.set_classes(&event.id, vec![open.clone(), spec.clone()]) + .unwrap(); + reg.set_roster(&event.id, vec![a.clone(), b.clone()]) + .unwrap(); + reg.set_class_membership( + &event.id, + open.clone(), + vec![MemberSlot { + pilot: a.clone(), + channel: Some(5658), + }], + ) + .unwrap(); + reg.set_class_membership(&event.id, spec.clone(), slots(vec![b.clone()])) + .unwrap(); + + // Deselect Spec: its membership entry goes; Open's survives channel-intact. + let meta = reg.set_classes(&event.id, vec![open.clone()]).unwrap(); + assert_eq!(meta.classes, vec![open.clone()]); + assert_eq!(meta.classes_membership.len(), 1); + assert_eq!(meta.classes_membership[0].class, open); + assert_eq!(meta.classes_membership[0].pilots[0].pilot, a); + assert_eq!(meta.classes_membership[0].pilots[0].channel, Some(5658)); + } + + #[test] + fn scored_round_requires_an_end_condition() { + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Ends Event")).unwrap(); + let open = seed_class(®, "Open"); + reg.set_classes(&event.id, vec![open.clone()]).unwrap(); + + // Best-lap only ranks; a scored round with no race time would never end → rejected. + let mut no_end = round_req("R", vec![open.clone()]); + no_end.win_condition = Some(WinCondition::BestLap); + no_end.time_limit_secs = None; + assert!(matches!( + reg.add_round(&event.id, no_end), + Err(RoundError::Invalid(_)) + )); + + // Omitting the win condition (defaults to Best Lap) with no race time is also rejected. + let mut omitted = round_req("R", vec![open.clone()]); + omitted.win_condition = None; + omitted.time_limit_secs = None; + assert!(matches!( + reg.add_round(&event.id, omitted), + Err(RoundError::Invalid(_)) + )); + + // Best-lap WITH a race time is accepted. + let mut with_time = round_req("R", vec![open.clone()]); + with_time.time_limit_secs = Some(120); + assert!(reg.add_round(&event.id, with_time).is_ok()); + + // A self-terminating win condition (First-to-N) needs no time limit. + let mut first_to = round_req("R", vec![open.clone()]); + first_to.win_condition = Some(WinCondition::FirstToLaps { n: 3 }); + first_to.time_limit_secs = None; + assert!(reg.add_round(&event.id, first_to).is_ok()); + + // Open practice is EXEMPT — no win condition + no time limit is valid (runs until ForceEnd). + let mut practice = round_req("Practice", vec![]); + practice.format = "open_practice".to_string(); + practice.win_condition = None; + practice.time_limit_secs = None; + practice.seeding = SeedingRule::AllChannels { + channels: vec![0, 1], + }; + assert!(reg.add_round(&event.id, practice).is_ok()); + } + + #[test] + fn from_heat_winners_seeding_accepts_either_source_key() { + use serde_json::from_str; + let canonical = SeedingRule::FromHeatWinners { + source_round: RoundId("qf".into()), + }; + // Singular `source_round` (canonical) and a one-element `source_rounds` both deserialize. + assert_eq!( + from_str::(r#"{"FromHeatWinners":{"source_round":"qf"}}"#).unwrap(), + canonical + ); + assert_eq!( + from_str::(r#"{"FromHeatWinners":{"source_rounds":["qf"]}}"#).unwrap(), + canonical + ); + // A multi-element `source_rounds` is rejected (FromHeatWinners is single-source), as is none. + assert!( + from_str::(r#"{"FromHeatWinners":{"source_rounds":["a","b"]}}"#).is_err() + ); + assert!(from_str::(r#"{"FromHeatWinners":{}}"#).is_err()); + } + + #[test] + fn shelved_zippyq_round_still_validates_and_round_trips() { + // #218: ZippyQ is **shelved** — removed from the offered format set (`standard_schemas`) so a + // new round can't pick it, but the generator stays **registered** in + // `FormatRegistry::standard()`. This is the persistence-stability guarantee: an event that + // already stored a `zippyq` round (or the renamed-display `timed_qual` round) must still load + // and validate. Adding a `zippyq` round through the same validation path the loader uses must + // therefore succeed, and the persisted on-disk shape must round-trip unchanged. + let reg = EventRegistry::new(None).unwrap(); + let event = reg.create(&req("Legacy Event")).unwrap(); + let open = seed_class(®, "Open"); + reg.set_classes(&event.id, vec![open.clone()]).unwrap(); + + // `timed_qual` (the format whose *display* name became "Time Trials" — wire key unchanged). + let tq = reg + .add_round(&event.id, round_req("Time Trials R1", vec![open.clone()])) + .unwrap(); + assert_eq!(tq.format, "timed_qual"); + + // A `zippyq` round still passes validation (the generator is registered, just not offered). + let mut zippy = round_req("Legacy ZippyQ", vec![open]); + zippy.format = "zippyq".to_string(); + let zippy = reg + .add_round(&event.id, zippy) + .expect("a persisted zippyq round must still validate (shelved, not removed)"); + assert_eq!(zippy.format, "zippyq"); + + // The event with both formats serializes and deserializes unchanged (the on-disk shape an + // older Director wrote still loads bit-for-bit on the renamed/shelved build). + let meta = reg + .list() + .into_iter() + .find(|m| m.id == event.id) + .expect("event present"); + let json = serde_json::to_string(&meta).unwrap(); + let restored: EventMeta = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, meta); + let formats: Vec<&str> = restored.rounds.iter().map(|r| r.format.as_str()).collect(); + assert!(formats.contains(&"timed_qual") && formats.contains(&"zippyq")); + } + + #[test] + fn from_ranking_deserializes_legacy_single_source_round() { + // A round stored before issue #51 wrote a single `source_round` string. It must still + // deserialize, lifting the legacy key into a one-element `source_rounds` list. + let legacy = r#"{ "FromRanking": { "source_round": "qual-r1", "top_n": 4 } }"#; + let rule: SeedingRule = serde_json::from_str(legacy).unwrap(); + assert_eq!( + rule, + SeedingRule::FromRanking { + source_rounds: vec![RoundId("qual-r1".into())], + top_n: 4, + } + ); + + // The current multi-round shape deserializes directly. + let current = + r#"{ "FromRanking": { "source_rounds": ["qual-r1", "qual-r2"], "top_n": 8 } }"#; + let rule: SeedingRule = serde_json::from_str(current).unwrap(); + assert_eq!( + rule, + SeedingRule::FromRanking { + source_rounds: vec![RoundId("qual-r1".into()), RoundId("qual-r2".into())], + top_n: 8, + } + ); + + // Round-trips through serialize → deserialize on the current shape. + let json = serde_json::to_string(&rule).unwrap(); + let back: SeedingRule = serde_json::from_str(&json).unwrap(); + assert_eq!(rule, back); + + // The other variants are unaffected. + let roster: SeedingRule = serde_json::from_str(r#""FromRoster""#).unwrap(); + assert_eq!(roster, SeedingRule::FromRoster); + let channels: SeedingRule = + serde_json::from_str(r#"{ "AllChannels": { "channels": [0, 1, 2] } }"#).unwrap(); + assert_eq!( + channels, + SeedingRule::AllChannels { + channels: vec![0, 1, 2] + } + ); + + // FromHeatWinners (bracket advancement, #217) deserializes from its single source round + // and round-trips through serialize. + let winners: SeedingRule = + serde_json::from_str(r#"{ "FromHeatWinners": { "source_round": "quarters" } }"#) + .unwrap(); + assert_eq!( + winners, + SeedingRule::FromHeatWinners { + source_round: RoundId("quarters".into()), + } + ); + let json = serde_json::to_string(&winners).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + winners, + "FromHeatWinners round-trips through serialize → deserialize" + ); + } + + #[test] + fn multi_main_seeding_round_trips_and_is_back_compat() { + use serde_json::from_str; + + // FromRankingRange: current `source_rounds` shape + skip/take. + let range = SeedingRule::FromRankingRange { + source_rounds: vec![RoundId("qual".into())], + skip: 12, + take: 8, + }; + let json = serde_json::to_string(&range).unwrap(); + assert_eq!(from_str::(&json).unwrap(), range); + + // FromRankingRange accepts the legacy single `source_round` key (lifted to a one-element list). + let legacy_range = r#"{"FromRankingRange":{"source_round":"qual","skip":12,"take":8}}"#; + assert_eq!(from_str::(legacy_range).unwrap(), range); + + // Combine — including a NESTED Combine — round-trips through serialize → deserialize (the + // recursive self-reference dispatches back through the hand-written Deserialize). + let combine = SeedingRule::Combine { + sources: vec![ + SeedingRule::FromRankingRange { + source_rounds: vec![RoundId("qual".into())], + skip: 6, + take: 6, + }, + SeedingRule::Combine { + sources: vec![SeedingRule::FromRanking { + source_rounds: vec![RoundId("c-final".into())], + top_n: 2, + }], + }, + ], + }; + let json = serde_json::to_string(&combine).unwrap(); + assert_eq!( + from_str::(&json).unwrap(), + combine, + "a nested Combine round-trips" + ); + + // The legacy variants are unaffected by the additive variants. + assert_eq!( + from_str::(r#""FromRoster""#).unwrap(), + SeedingRule::FromRoster + ); + assert_eq!( + from_str::(r#"{"FromRanking":{"source_round":"q","top_n":4}}"#).unwrap(), + SeedingRule::FromRanking { + source_rounds: vec![RoundId("q".into())], + top_n: 4, + } + ); + assert_eq!( + from_str::(r#"{"FromHeatWinners":{"source_round":"qf"}}"#).unwrap(), + SeedingRule::FromHeatWinners { + source_round: RoundId("qf".into()), + } + ); + } + + #[test] + fn rounds_persist_across_a_restart() { + // The #115 meta mechanism must carry the additive rounds list through a Director restart. + let dir = std::env::temp_dir().join(format!("gridfpv-rounds-test-{}", short_suffix())); + let created_id; + let round_id; + { + let reg = EventRegistry::new(Some(dir.clone())).unwrap(); + let created = reg.create(&req("Persisted Rounds")).unwrap(); + created_id = created.id.clone(); + let open = seed_class(®, "Open"); + reg.set_classes(&created.id, vec![open.clone()]).unwrap(); + let round = reg + .add_round(&created.id, round_req("Qualifying R1", vec![open])) + .unwrap(); + round_id = round.id.clone(); + } + // Restart: a brand-new registry over the SAME data dir. + let reopened = EventRegistry::new(Some(dir.clone())).unwrap(); + let restored = reopened.meta_of(&created_id).expect("event reloaded"); + assert_eq!(restored.rounds.len(), 1); + assert_eq!(restored.rounds[0].id, round_id); + assert_eq!(restored.rounds[0].label, "Qualifying R1"); + assert_eq!(restored.rounds[0].format, "timed_qual"); + assert_eq!(restored.rounds[0].seeding, SeedingRule::FromRoster); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn restores_an_older_additive_event_meta_json() { + // P1-6 back-compat: a known-good *older* EventMeta JSON (only the pre-#73 core fields, + // before timers/roster/classes/rounds existed) must still load on a newer Director — the + // additive fields take their serde defaults. Mirrors the pilots/classes/timers back-compat + // load tests, and guards the loud-skip logging added for unparseable meta. + let dir = std::env::temp_dir().join(format!("gridfpv-meta-backcompat-{}", short_suffix())); + std::fs::create_dir_all(&dir).unwrap(); + let id = EventId("legacy-event".into()); + let legacy_json = r#"{ + "id": "legacy-event", + "name": "Legacy Spring Cup", + "created_at": 1700000000000, + "persistent": true + }"#; + { + // Hand-write the older shape directly into the event's sqlite meta sidecar. + let path = event_db_path(&dir, &id); + let log = SqliteLog::open(&path).unwrap(); + log.set_meta(EVENT_META_KEY, legacy_json).unwrap(); + } + // Reopen the registry over the dir → the older event restores with defaulted additive fields. + let reg = EventRegistry::new(Some(dir.clone())).unwrap(); + let restored = reg.meta_of(&id).expect("older event restored"); + assert_eq!(restored.name, "Legacy Spring Cup"); + assert_eq!(restored.created_at, 1_700_000_000_000); + assert!(restored.persistent); + assert!(restored.timers.is_empty()); + assert!(restored.roster.is_empty()); + assert!(restored.classes.is_empty()); + assert!(restored.rounds.is_empty()); + assert!(restored.classes_membership.is_empty()); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs new file mode 100644 index 0000000..48dbaef --- /dev/null +++ b/crates/server/src/lib.rs @@ -0,0 +1,257 @@ +//! GridFPV protocol server — the one read/realtime contract (snapshot + WS change +//! 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, 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), 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 +//! 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; 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 +//! 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 auth; +pub mod channels; +pub mod classes; +pub mod control; +pub mod control_handler; +pub mod error; +pub mod events; +pub mod live_state; +pub mod open_practice; +pub mod pilots; +pub mod plugin_bundle; +pub mod round_engine; +pub mod scope; +pub mod snapshot; +pub mod stream; +pub mod timers; +pub mod ws; + +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 **oldest** contract version this server still serves (protocol.html §7, §9.7) — +/// the bottom of the supported band `MIN_SUPPORTED_CONTRACT_VERSION..=CONTRACT_VERSION`. +/// +/// A client whose [`Hello::contract_version`] is below this is "too old, please refresh"; +/// one above [`CONTRACT_VERSION`] is "too new". Both fall outside the band and get the +/// refresh signal (a [`ServerHello`] with `compatible = false`, or a +/// [`VersionMismatch`](error::ErrorCode::VersionMismatch) error on a path that cannot +/// carry a `ServerHello`). At the first contract the band is the single version `1..=1`; +/// it widens as the server keeps serving older clients across additive changes (§7). +pub const MIN_SUPPORTED_CONTRACT_VERSION: ContractVersion = ContractVersion::new(1); + +/// Whether `client` falls within this server's supported contract band +/// (protocol.html §7): `MIN_SUPPORTED_CONTRACT_VERSION..=CONTRACT_VERSION`, inclusive. +/// `false` is the "too old / too new, please refresh" condition. +pub fn is_supported_contract_version(client: ContractVersion) -> bool { + MIN_SUPPORTED_CONTRACT_VERSION <= client && client <= CONTRACT_VERSION +} + +/// 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, +} + +impl ServerHello { + /// The server's reply for a client announcing `client` (protocol.html §7): this + /// build's supported band `[MIN_SUPPORTED_CONTRACT_VERSION, CONTRACT_VERSION]` with + /// `compatible` set by [`is_supported_contract_version`]. The single place the band is + /// turned into a handshake reply, so every transport (a future `Hello` exchange, the + /// WS subscribe) reports the same range. + pub fn for_client(client: ContractVersion) -> Self { + Self { + min_contract_version: MIN_SUPPORTED_CONTRACT_VERSION, + max_contract_version: CONTRACT_VERSION, + compatible: is_supported_contract_version(client), + } + } + + /// The "too old / too new, please refresh" error for an out-of-band `client` + /// (protocol.html §7, §9.8) — the [`VersionMismatch`](error::ErrorCode::VersionMismatch) + /// form of the refresh signal for a path that cannot carry a `ServerHello` (the WS + /// subscribe close frame, a control hello). The message names the served band so the + /// client knows whether to upgrade or downgrade. + pub fn refresh_error(client: ContractVersion) -> error::ProtocolError { + error::ProtocolError::new( + error::ErrorCode::VersionMismatch, + format!( + "contract version {} is outside this server's supported band {}..={} — \ + please refresh the client", + client.version, MIN_SUPPORTED_CONTRACT_VERSION.version, CONTRACT_VERSION.version, + ), + ) + } +} + +#[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 in_band_version_is_supported() { + assert!(is_supported_contract_version(CONTRACT_VERSION)); + assert!(is_supported_contract_version( + MIN_SUPPORTED_CONTRACT_VERSION + )); + let reply = ServerHello::for_client(CONTRACT_VERSION); + assert!(reply.compatible); + assert_eq!(reply.min_contract_version, MIN_SUPPORTED_CONTRACT_VERSION); + assert_eq!(reply.max_contract_version, CONTRACT_VERSION); + } + + #[test] + fn out_of_band_version_gets_the_refresh_signal() { + // Too new (above the band). + let too_new = ContractVersion::new(CONTRACT_VERSION.version + 1); + assert!(!is_supported_contract_version(too_new)); + assert!(!ServerHello::for_client(too_new).compatible); + assert_eq!( + ServerHello::refresh_error(too_new).code, + error::ErrorCode::VersionMismatch + ); + + // Too old (below the band), when one exists. + if MIN_SUPPORTED_CONTRACT_VERSION.version > 0 { + let too_old = ContractVersion::new(MIN_SUPPORTED_CONTRACT_VERSION.version - 1); + assert!(!is_supported_contract_version(too_old)); + assert!(!ServerHello::for_client(too_old).compatible); + } + } + + #[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/live_state.rs b/crates/server/src/live_state.rs new file mode 100644 index 0000000..550fdac --- /dev/null +++ b/crates/server/src/live_state.rs @@ -0,0 +1,1983 @@ +//! 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 one the RD is showing/controlling in +//! Live control. A freshly-**scheduled** heat must not steal focus (filling a heat only +//! adds it to the list / on-deck), so the current heat is the one referenced by the **last +//! event among `{HeatStateChanged, CurrentHeatSelected}`** — a real heat-loop transition +//! (Stage/Start/…) or the RD's explicit manual selection +//! ([`Event::CurrentHeatSelected`](gridfpv_events::Event::CurrentHeatSelected)). Before +//! either has occurred we fall back to the **first `HeatScheduled`**, so the very first +//! heat is controllable. A heat that has reached the terminal `Final` phase is still +//! reported as the current heat until a *newer* heat takes the timer (a transition or a +//! selection), 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::{ClassId, CompetitorRef, Event, HeatId, HeatTransition, PilotId, RoundId}; +use gridfpv_projection::{CompetitorKey, lap_list_marshaled_with_floor, registrations}; +use gridfpv_storage::StoredEvent; +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, + /// The **server-authoritative race-start instant** of the current heat: the + /// `recorded_at` (microseconds, server wall clock) of the `Armed → Running` + /// transition that put it on the timer (the real race-go). `None` before the heat + /// has started (or for an older log whose transition carried no timestamp). + /// + /// This is the anchor every client clock counts from — header and HUD alike — so the + /// elapsed reads identically and accurately *regardless of when the client mounted* + /// (the #62 follow-up: kill the per-client wall-clock drift). Renders as a plain TS + /// `number` (microseconds, bounded far below 2^53). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub race_started_at: Option, + /// The **server-authoritative race-end instant** of the current heat: the + /// `recorded_at` (microseconds, server wall clock) of the `Running → Unofficial` + /// transition that closed it. `None` while the heat is still running (or has not + /// started). When set, `race_ended_at - race_started_at` is the **exact** race + /// duration a frozen clock reads (so a 60s limit reads `1:00.000`, not `1:00.100`). + /// Renders as a plain TS `number` (microseconds). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub race_ended_at: Option, + /// The **server-authoritative staging-start instant** of the current heat while it is + /// `Staged`: the `recorded_at` (microseconds, server wall clock) of its latest + /// `Scheduled → Staged` transition. The staging countdown anchors here — a per-client + /// wall-clock anchor made every console (and every reload) count its own window, so the + /// RD's console could read overtime while a fresh one read the full window. `None` in + /// every non-Staged phase. Renders as a plain TS `number` (microseconds). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub staged_at: Option, + /// The **server-authoritative start-tone instant** of the current heat while it is `Armed`: + /// the absolute wall-clock instant (microseconds since the Unix epoch) the start tone fires + /// and the heat auto-advances `Armed → Running`. Derived from the `recorded_at` of the heat's + /// [`HeatStarting`](gridfpv_events::Event::HeatStarting) event plus its logged `delay_ms` (the + /// chosen randomized hold). `None` once the heat is `Running`/past (or before it has armed). + /// + /// **RD-console-only:** the start delay is *intentionally random to pilots*, so this is the + /// anchor the RD's control view counts down to ("tone in 0:03") — the read-only / pilot view + /// never renders it. It is `None` once `race_started_at` is set, so a late join after race-go + /// sees no stale countdown. Renders as a plain TS `number` (microseconds). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub tone_at: Option, + /// The **provisional → official lifecycle** of the current heat (marshaling Slice 5, + /// marshaling.html §3.3), surfaced for the Marshaling/Live UI. `None` until the heat reaches the + /// `Unofficial` phase (before that there is no result to be provisional about). Once provisional + /// it carries the auto-official deadline (when a protest window is armed) so the console can show + /// a "Provisional — auto-official in M:SS" countdown; `Official` once finalized. Derived from the + /// heat's phase + the logged [`HeatFinalizing`](gridfpv_events::Event::HeatFinalizing) deadline, + /// so it folds deterministically like the rest of this projection. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub lifecycle: Option, +} + +/// The provisional → official lifecycle of a heat's result (marshaling Slice 5), projected for the +/// UI from the heat FSM + the logged auto-official deadline. +/// +/// This is **not** a new FSM state: it reuses the heat's existing `Unofficial` (provisional, +/// correctable) → `Final` (official) transition. [`Provisional`](Self::Provisional) maps onto +/// `Unofficial`; [`Official`](Self::Official) onto `Final`. The optional `auto_official_at` is the +/// server-clock instant the runtime will auto-finalize, present only when the round armed a +/// [`ProtestWindow::After`](gridfpv_engine::heat::ProtestWindow::After). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum LifecycleState { + /// The result is **provisional** (the heat is `Unofficial`): correctable, not yet official. When + /// a protest window is armed, `auto_official_at` is the server wall-clock instant (microseconds + /// since the Unix epoch) at which the runtime auto-finalizes; the console renders `at − now` as a + /// countdown. `None` ⇒ no auto-official timer (manual finalize only — the default). + Provisional { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + auto_official_at: Option, + }, + /// The result is **official** (the heat is `Final`). `Revert` can re-open it to `Provisional`. + Official, +} + +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, + race_started_at: None, + race_ended_at: None, + staged_at: None, + tone_at: None, + lifecycle: 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, + /// The GridFPV pilot this competitor is bound to, if a registration + /// ([`Event::CompetitorRegistered`]) has bound it (#60). `None` for an unregistered + /// competitor, which still appears by its bare [`CompetitorRef`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub pilot: Option, + /// 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. Renders as a plain TS `number` (bounded far below 2^53). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + 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 window: Vec<(u64, &Event)> = events + .iter() + .enumerate() + .map(|(i, e)| (i as u64, e)) + .collect(); + live_state_core(events, &window, None) +} + +/// Fold a **windowed** log slice with its PRESERVED global offsets — the heat/class-scope +/// entry point (`heat_window_offsets` / `class_window_offsets` output). The marshaling +/// adjudications inside the window (`DetectionVoided`/`LapThrownOut`/…) target global +/// [`LogRef`]s; folding a filtered slice through the plain [`live_state`] re-enumerated it +/// `0,1,2,…`, so a correction to a heat deep in the log silently missed (or, on coincidence, +/// hit the wrong pass) in every heat-scoped live view. `window` must be in log order. +pub fn live_state_over(window: &[(u64, Event)]) -> LiveRaceState { + live_state_over_with_floor(window, None) +} + +/// [`live_state_over`] under the current heat's **min-lap floor** (D26): the live lap fold +/// suppresses under-floor raw passes exactly like the laps/result projections, so the race +/// screen's lap counts and the marshaling list can never disagree about an echo. +pub fn live_state_over_with_floor( + window: &[(u64, Event)], + min_lap_micros: Option, +) -> LiveRaceState { + // The positional helpers (current heat, phase, lineup, run boundary) read a bare event + // slice; the offsets matter only to the marshaling-aware lap fold below. + let events: Vec = window.iter().map(|(_, e)| e.clone()).collect(); + let pairs: Vec<(u64, &Event)> = window.iter().map(|(o, e)| (*o, e)).collect(); + live_state_core(&events, &pairs, min_lap_micros) +} + +/// The shared fold behind [`live_state`] (full log, positional offsets) and +/// [`live_state_over`] (a window with preserved global offsets). `window` is the SAME +/// sequence as `events`, paired with each event's global append offset. +fn live_state_core( + events: &[Event], + window: &[(u64, &Event)], + min_lap_micros: Option, +) -> 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 marshaling-aware lap projection and index laps by competitor + // ref, summing across adapters for a competitor seen on more than one. + // + // **Scope to the current run.** Abort/Restart send the heat back to `Scheduled` but leave the + // aborted run's `Pass`es in the log; counting the whole log would keep showing (and a re-run + // would double-count) those stale laps. So we window the fold to events at/after the heat's + // latest reset boundary — the live count is *only* this run's passes (zero right after a reset, + // counted from zero on the re-run). The global offset each event is fed under is preserved so a + // marshaling adjudication still addresses the right pass. A heat that never reset has no + // boundary, so everything counts (a normally-finalized heat is unaffected). + let run_start = current_run_start(events, ¤t_heat); + let pass_ceiling = current_run_pass_ceiling(events, ¤t_heat); + let laps = lap_list_marshaled_with_floor( + window + .iter() + .enumerate() + .filter(|(i, (_, e))| { + if *i < run_start { + return false; + } + // Tag-aware pass attribution: a pass stamped for ANOTHER heat never counts + // toward this one — selecting an older heat as current used to absorb every + // later heat's passes (they all sit after its run_start). An untagged + // (legacy) pass keeps the positional rule. Either way a pass landing AFTER + // the run went official is frozen out (the Final freeze). + match e { + Event::Pass(p) => { + *i < pass_ceiling && p.heat.as_ref().is_none_or(|h| h == ¤t_heat) + } + _ => true, + } + }) + .map(|(_, (offset, e))| (*offset, *e)), + min_lap_micros, + ); + // Per ref: lap count, the last lap's DURATION (the wire's `last_lap_micros` display value), + // and the last lap's COMPLETION time (`at`) — the running-order tie-break. The scorer ranks + // equal-lap pilots by earlier last-lap completion; ordering on duration here made the live + // overlay contradict the scored Timed result (a slower-but-ahead pilot showed behind). + let mut by_ref: BTreeMap<&CompetitorRef, (u32, Option, Option)> = BTreeMap::new(); + for cl in &laps.competitors { + let CompetitorKey { competitor, .. } = &cl.competitor; + let entry = by_ref.entry(competitor).or_insert((0, None, None)); + entry.0 += cl.lap_count() as u32; + if let Some(last) = cl.laps.last() { + // Across adapters, keep the TEMPORALLY latest lap (not whichever adapter + // iterates later). + if entry.2.is_none_or(|prev| last.at.micros >= prev) { + entry.1 = Some(last.duration_micros); + entry.2 = Some(last.at.micros); + } + } + } + + // Fold the registration bindings and index them by competitor ref. The lineup carries + // only the bare `CompetitorRef`; registrations are keyed per-source `(adapter, + // competitor)`, so collapse to a ref→pilot lookup. The fold already applied + // last-registration-wins per key; iterating the map in (adapter, competitor) order + // keeps the collapse deterministic when one ref is bound on more than one adapter. + let bindings = registrations(events); + let pilot_by_ref: BTreeMap<&CompetitorRef, &PilotId> = bindings + .iter() + .map(|(CompetitorKey { competitor, .. }, pilot)| (competitor, pilot)) + .collect(); + + let progress: Vec = active_pilots + .iter() + .map(|competitor| { + let (laps_completed, last_lap_micros, _) = + by_ref.get(competitor).copied().unwrap_or((0, None, None)); + PilotProgress { + competitor: competitor.clone(), + pilot: pilot_by_ref.get(competitor).map(|p| (*p).clone()), + laps_completed, + last_lap_micros, + } + }) + .collect(); + + // Rank by lap count, ties by EARLIER last-lap completion (the scorer's rule — see + // `score_timed`), never by lap duration: physically ahead means crossed sooner. + let last_completion: BTreeMap<&CompetitorRef, i64> = by_ref + .iter() + .filter_map(|(competitor, (_, _, at))| at.map(|at| (*competitor, at))) + .collect(); + let running_order = running_order_by_completion(&progress, &last_completion); + + LiveRaceState { + current_heat: Some(current_heat.clone()), + phase, + active_pilots, + progress, + running_order, + on_deck: on_deck(events, ¤t_heat), + // Timing is set by [`with_heat_timing`] from the *stored* log (which carries the + // `recorded_at` server timestamps the bare `Event` slice lacks). The plain fold over + // `&[Event]` — the open-practice synthetic path and the unit tests — leaves it `None`. + race_started_at: None, + race_ended_at: None, + staged_at: None, + // Likewise set by [`with_heat_timing`]: the start-tone instant needs the `HeatStarting` + // event's `recorded_at`, which the bare-event fold cannot see. + tone_at: None, + // The lifecycle is likewise finished by [`with_heat_timing`] from the *stored* log (it needs + // the `HeatFinalizing` deadline's `recorded_at` context); the bare-event fold leaves it `None`. + lifecycle: None, + } +} + +/// Fold a heat's **server-authoritative race timing** out of the stored log: the +/// `recorded_at` of its `Armed → Running` transition (the race-start) and of its +/// `Running → Unofficial` transition (the race-end), if each has occurred. +/// +/// Pure and recomputable like [`live_state`]: it reads the timestamps the log already +/// records (the runtime stamps each heat transition at append time), so a replayed session +/// folds the same instants. The last matching transition wins (a heat re-run after an +/// abort uses its latest `Running`). +pub fn heat_timing(stored: &[StoredEvent], heat: &HeatId) -> (Option, Option) { + let mut started_at = None; + let mut ended_at = None; + for entry in stored { + if let Event::HeatStateChanged { + heat: h, + transition, + } = &entry.event + { + if h != heat { + continue; + } + match transition { + // `Armed → Running` is the real race-go; a re-run after an abort restamps it, + // so clear any prior end too — the new run hasn't ended yet. + HeatTransition::Running => { + started_at = entry.recorded_at; + ended_at = None; + } + // `Running → Unofficial` (the engine names it `Finished`) is the race-end. + HeatTransition::Finished => ended_at = entry.recorded_at, + _ => {} + } + } + } + (started_at, ended_at) +} + +/// The current heat's **auto-official deadline** (marshaling Slice 5): the `at` of the most-recent +/// [`HeatFinalizing`](Event::HeatFinalizing) the runtime logged when it armed the protest window, +/// cleared by any later `Running` for the heat (a re-run re-opens the window from scratch). +/// +/// Pure and log-derivable like [`heat_timing`]: the runtime writes the deadline once, at emission +/// time (mirroring `HeatStarting`'s logged delay), so a replay folds the same instant and the +/// countdown is identical for every client. `None` when no protest window was armed. +fn heat_finalizing_at(events: &[Event], heat: &HeatId) -> Option { + let mut at = None; + for event in events { + match event { + Event::HeatFinalizing { heat: h, at: a } if h == heat => at = Some(*a), + // A fresh run re-opens the window: drop a stale deadline so an aborted/reverted run's + // arming never lingers onto the new one (it re-arms with its own `HeatFinalizing`). + Event::HeatStateChanged { + heat: h, + transition: HeatTransition::Running, + } if h == heat => at = None, + _ => {} + } + } + at +} + +/// The current heat's **start-tone instant** while it is `Armed` (heat-lifecycle Slice 2/3): the +/// absolute server wall-clock instant (microseconds) the start tone fires and the heat +/// auto-advances `Armed → Running`. +/// +/// Derived from the heat's most-recent [`HeatStarting`](Event::HeatStarting): the `recorded_at` of +/// that event (the instant the heat armed) plus its logged `delay_ms` (the chosen randomized hold, +/// in **milliseconds** → microseconds). The runtime writes the delay once, at emission time +/// (mirroring `HeatFinalizing`'s deadline), so a replay folds the *same* instant and every +/// controlling client counts down identically. +/// +/// Cleared by any later `Running` for the heat (the tone has fired — `race_started_at` takes over) +/// or a re-arm sequence: a fresh `HeatStarting` restamps it, a `Running` drops it. Pure and +/// log-derivable like [`heat_timing`]. `None` when the heat has not armed (no `HeatStarting`), the +/// `HeatStarting` carried no `recorded_at` (an older/untimed entry), or the heat has since run. +fn heat_tone_at(stored: &[StoredEvent], heat: &HeatId) -> Option { + let mut at = None; + for entry in stored { + match &entry.event { + // The start procedure armed: the tone fires at this instant + the chosen hold. + Event::HeatStarting { heat: h, delay_ms } if h == heat => { + at = entry + .recorded_at + .map(|armed| armed + i64::from(*delay_ms) * 1_000); + } + // The tone has fired (the heat is now Running) — `race_started_at` is the anchor from + // here, so drop the pending tone instant. A re-arm logs a fresh `HeatStarting` above. + Event::HeatStateChanged { + heat: h, + transition: HeatTransition::Running, + } if h == heat => at = None, + _ => {} + } + } + at +} + +/// Set a folded [`LiveRaceState`]'s server-authoritative timing **and lifecycle** from the stored +/// log. +/// +/// The plain [`live_state`] fold (over `&[Event]`) cannot see `recorded_at`; the snapshot +/// and change-stream paths hold the full [`StoredEvent`] log, so they finish the live state +/// by folding the current heat's race-start / race-end instants in here. A no-op when there +/// is no current heat. Idempotent and order-independent — just a finishing fold. +/// +/// The **lifecycle** (marshaling Slice 5) is derived alongside: an `Unofficial` heat is +/// [`Provisional`](LifecycleState::Provisional) (carrying the logged auto-official deadline, if a +/// protest window was armed), a `Final` heat is [`Official`](LifecycleState::Official), and any +/// earlier phase has no lifecycle yet (`None`). +pub fn with_heat_timing(mut live: LiveRaceState, stored: &[StoredEvent]) -> LiveRaceState { + if let Some(heat) = live.current_heat.clone() { + let (started, ended) = heat_timing(stored, &heat); + live.race_started_at = started; + live.race_ended_at = ended; + // The staging countdown anchor — present only while the heat is `Staged` (phase-gated + // like `tone_at`, so a re-run's stale Staged instant never leaks into another phase). + live.staged_at = match live.phase { + HeatPhase::Staged => heat_staged_at(stored, &heat), + _ => None, + }; + // The start-tone countdown anchor — present only while the heat is `Armed` (the tone has + // not fired yet). `heat_tone_at` already clears it on `Running`; gating on the phase keeps + // it absent in every non-Armed phase even if the fold order ever changed. RD-console-only: + // the field surfaces it, the client gates rendering to a controlling session. + live.tone_at = match live.phase { + HeatPhase::Armed => heat_tone_at(stored, &heat), + _ => None, + }; + live.lifecycle = match live.phase { + HeatPhase::Unofficial => { + let events: Vec = stored.iter().map(|s| s.event.clone()).collect(); + Some(LifecycleState::Provisional { + auto_official_at: heat_finalizing_at(&events, &heat), + }) + } + HeatPhase::Final => Some(LifecycleState::Official), + _ => None, + }; + } + live +} + +/// The `recorded_at` of `heat`'s **latest `Staged` transition** — the staging-countdown anchor +/// (`LiveRaceState::staged_at`). Last-wins so a re-staged heat (after an abort) anchors its new +/// staging window, not the abandoned one. `None` when the heat never staged or the entry +/// carries no timestamp. +fn heat_staged_at(stored: &[StoredEvent], heat: &HeatId) -> Option { + stored + .iter() + .filter_map(|entry| match &entry.event { + Event::HeatStateChanged { + heat: h, + transition: HeatTransition::Staged, + } if h == heat => entry.recorded_at, + _ => None, + }) + .next_back() +} + +/// 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::Unofficial => HeatPhase::Unofficial, + HeatState::Final => HeatPhase::Final, + } +} + +/// The current heat: the heat the RD is showing/controlling in Live control. +/// +/// A brand-new **scheduled** heat must *not* grab focus (filling heats only adds them to +/// the list / on-deck), so the current heat is the one referenced by the **last event +/// among `{HeatStateChanged, CurrentHeatSelected}`** — a real heat-loop transition +/// (Stage/Start/…) or an explicit manual selection. If there has been neither yet, fall +/// back to the **first `HeatScheduled`** so the very first heat is controllable before any +/// transition. `None` if no heat was ever scheduled. +fn current_heat(events: &[Event]) -> Option { + let mut active: Option = None; + let mut first_scheduled: Option = None; + for event in events { + match event { + Event::HeatStateChanged { heat, .. } | Event::CurrentHeatSelected { heat } => { + active = Some(heat.clone()); + } + Event::HeatScheduled { heat, .. } if first_scheduled.is_none() => { + first_scheduled = Some(heat.clone()); + } + _ => {} + } + } + active.or(first_scheduled) +} + +/// The log index where the current heat's **current run** begins — the boundary the live lap count / +/// standings / heat sheet fold from (`>= current_run_start`), so they reflect **only this heat's +/// current run**. It is the latest of, for `heat`: +/// +/// - its most recent `Running` transition (the run started here — passes only arrive while Running), +/// **or** +/// - one past its most recent reset (`Aborted` / `Restarted` / `Discarded`). +/// +/// This scopes the window two ways at once. **Across heats:** a heat's `Running` is logged after every +/// earlier heat finished, so folding from it excludes those earlier heats' passes — a pilot who flew +/// prior heats (a bracket level, a round-robin, a multi-round qualifier) shows only *this* heat's laps, +/// not a running total. **Within a heat:** a reset leaves the abandoned run's passes in the log; the +/// re-run's `Running` (or the reset boundary, before the re-run) sits after them, so the count restarts +/// from zero. If the heat has neither run nor reset yet (still `Scheduled`) the window is **empty** +/// (`events.len()`) ⇒ zero live laps — never a prior-heat total. +/// +/// Pure and order-preserving: it is the *latest* such transition for `heat`. +/// +/// Shared with the heat **window** (`app::heat_window_offsets`): scoring, the marshaling lap +/// list, and the audit fold all window from here too, so a Restarted heat's abandoned run +/// never leaks into its result (the same rule this fold applies to the live standings). +pub(crate) fn current_run_start(events: &[Event], heat: &HeatId) -> usize { + // Default to past-the-end: a heat that has not run yet contributes no live laps (so selecting an + // unraced heat as current shows zeros, not its pilots' totals from earlier heats). + let mut start = events.len(); + for (i, event) in events.iter().enumerate() { + if let Event::HeatStateChanged { + heat: h, + transition, + } = event + { + if h == heat { + match transition { + // The run begins at Running — fold this heat's passes (which follow it), excluding + // every earlier heat and any abandoned earlier run of this one. + HeatTransition::Running => start = i, + // A reset with no re-run yet: window past it so the abandoned passes drop out. + // `Discarded` is a reset too (Unofficial/Final → Scheduled, the result thrown + // away) — without it here a discarded heat kept showing, and re-scoring, the + // dead run's laps until its re-run (caught live in the overnight soak). + HeatTransition::Aborted + | HeatTransition::Restarted + | HeatTransition::Discarded => start = i + 1, + _ => {} + } + } + } + } + start +} + +/// The log index where the current run's **official record closes**: the first `Finalized` +/// transition for `heat` at/after [`current_run_start`], or `usize::MAX` while the run is not +/// (yet) official. Passes at/after this offset NEVER join the heat's window — once a result is +/// official it is frozen against late arrivals (a delayed RotorHazard catch-up pass tagged with +/// the heat used to silently change a Final result with no command, no ruling, and no audit +/// entry). A `Revert` re-opens marshaling (rulings are not passes and stay unaffected), but the +/// late passes stay out: the race's observation record ended with its run. +pub(crate) fn current_run_pass_ceiling(events: &[Event], heat: &HeatId) -> usize { + let run_start = current_run_start(events, heat); + events + .iter() + .enumerate() + .skip(run_start) + .find_map(|(i, e)| match e { + Event::HeatStateChanged { + heat: h, + transition: HeatTransition::Finalized, + } if h == heat => Some(i), + _ => None, + }) + .unwrap_or(usize::MAX) +} + +/// 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 next still-`Scheduled` heat **after the current one** in schedule order. +/// +/// "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. The on-deck heat is the first scheduled heat positioned *after* the current heat — +/// this is what both the on-deck display and the Advance control want: the next heat forward, +/// never an earlier one. +/// +/// **Fallback:** if there is no still-`Scheduled` heat after the current heat's position (the +/// current heat is last, or it isn't in the schedule at all), fall back to the first +/// still-`Scheduled` heat overall, so the RD can still advance to an earlier-scheduled-but-unrun +/// heat rather than getting stuck. The "after current" candidate is always preferred. +pub(crate) 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()); + } + } + } + let is_scheduled = |heat: &HeatId| heat_state(events, heat) == Some(HeatState::Scheduled); + // Position of the current heat in schedule order (if it appears at all). + let current_idx = seen.iter().position(|heat| heat == current); + // Prefer the first still-Scheduled heat positioned after the current heat. + if let Some(idx) = current_idx { + if let Some(next) = seen[idx + 1..].iter().find(|heat| is_scheduled(heat)) { + return Some(next.clone()); + } + } + // Fallback: the first still-Scheduled heat that is not the current one. + seen.iter() + .find(|heat| *heat != current && is_scheduled(heat)) + .cloned() +} + +/// The round a heat was scheduled under, from its most recent `HeatScheduled` (`None` for a +/// free-text / untagged heat). Used by the Advance control path to ask that round's generator +/// for the next heat when nothing is already on deck. +pub(crate) fn round_of_heat(events: &[Event], heat: &HeatId) -> Option { + latest_schedule(events, heat).2 +} + +/// Rank active pilots into the provisional running order with the SCORER's tie-break: +/// most laps first, ties broken by the **earlier last-lap completion time** (physically +/// ahead = crossed sooner — matching `score_timed`, so the live overlay agrees with the +/// eventual scored order), then by competitor ref for a total, deterministic order. +/// `last_completion` carries each ref's latest lap-closing pass instant (source µs); +/// a ref with laps but no completion entry sorts after those with one. +fn running_order_by_completion( + progress: &[PilotProgress], + last_completion: &BTreeMap<&CompetitorRef, i64>, +) -> Vec { + let mut order: Vec<&PilotProgress> = progress.iter().collect(); + order.sort_by(|a, b| { + b.laps_completed + .cmp(&a.laps_completed) + .then_with(|| { + match ( + last_completion.get(&a.competitor), + last_completion.get(&b.competitor), + ) { + (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() +} + +/// Rank by lap count with the last-lap **duration** tie-break — the legacy proxy, kept for +/// the open-practice per-channel board (whose in-memory laps carry durations, not global +/// completion instants). The real heat overlay uses [`running_order_by_completion`]. +pub(crate) 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() +} + +/// A scheduled heat as the **Heats UI** lists it (race redesign Slice 3b) — the per-heat view +/// model the Rounds & Heats stage renders under each round. +/// +/// Unlike [`LiveRaceState`] (which is *only* about the current heat), this is one entry per heat +/// the log ever scheduled, carrying the round/class tag the scheduler assigned so the UI can group +/// heats by round and resolve their lineup to pilots. Folded from the log by +/// [`heat_summaries`] — pure and recomputable like every other projection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct HeatSummary { + /// The heat's id (its scheduled handle, the same one the live/control path drives). + pub heat: HeatId, + /// The heat's lineup — the competitors from its most recent `HeatScheduled`, in lineup order. + pub lineup: Vec, + /// The class this heat was tagged with, when the scheduler assigned one (`None` for the + /// free-text / untagged path). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub class: Option, + /// The round this heat was tagged with, when the scheduler assigned one. The Heats UI groups + /// the list by this. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub round: Option, + /// The per-pilot **frequency assignment** of this heat, in raw MHz, paired with the competitor + /// it is assigned to — taken from the most recent `HeatScheduled` (race redesign Slice 4b). The + /// Heats/Live UI resolves each raw MHz back to a band+channel label via the channel catalog. + /// Empty when none was assigned (a sim heat, or the free-text path), in which case the UI shows + /// "—". Additive — defaults empty so older logs round-trip. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub frequencies: Vec<(CompetitorRef, u16)>, + /// The **optional human label** the RD typed when building this heat by hand, from the most + /// recent `HeatScheduled`. When present the Heats/Live UI shows it as the heat's display name + /// (overriding the derived "‹Round› Heat N" / tier convention); `None` for a generator-filled + /// heat, which keeps the auto-name. Additive — defaults absent so older logs round-trip. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub label: Option, + /// The heat's folded loop phase (its derived status: scheduled / running / final / …). + pub phase: HeatPhase, + /// Whether this heat is the one currently on the timer (the live `current_heat`). + pub is_current: bool, +} + +/// Fold the event log into the list of **scheduled heats** (race redesign Slice 3b), in +/// first-scheduled order — one [`HeatSummary`] per heat the log ever scheduled. +/// +/// Pure and order-preserving, mirroring [`live_state`]: a heat appears once (keyed on its id, the +/// latest `HeatScheduled` wins for lineup/class/round so a re-schedule updates the entry in place), +/// ordered by first appearance in the log (the order the generator emitted them). Each heat's +/// `phase` is its folded [`HeatState`] and `is_current` marks the one on the timer. The Heats UI +/// filters this by `round` to render each round's heats. +pub fn heat_summaries(events: &[Event]) -> Vec { + let current = current_heat(events); + + // First-scheduled order, deduped — collect the heat ids in the order they first appear. + let mut order: Vec = Vec::new(); + for event in events { + if let Event::HeatScheduled { heat, .. } = event { + if !order.contains(heat) { + order.push(heat.clone()); + } + } + } + + order + .into_iter() + .map(|heat| { + let (lineup, class, round, frequencies, label) = latest_schedule(events, &heat); + let phase = heat_state(events, &heat) + .map(phase_of) + .unwrap_or(HeatPhase::Scheduled); + let is_current = current.as_ref() == Some(&heat); + HeatSummary { + heat, + lineup, + class, + round, + frequencies, + label, + phase, + is_current, + } + }) + .collect() +} + +/// The lineup + class/round tag a heat carries, taken from its **most recent** `HeatScheduled` +/// (a re-schedule of the same id supersedes the earlier one). +#[allow(clippy::type_complexity)] +fn latest_schedule( + events: &[Event], + heat: &HeatId, +) -> ( + Vec, + Option, + Option, + Vec<(CompetitorRef, u16)>, + Option, +) { + let mut out = (Vec::new(), None, None, Vec::new(), None); + for event in events { + if let Event::HeatScheduled { + heat: h, + lineup, + class, + round, + frequencies, + label, + } = event + { + if h == heat { + out = ( + lineup.clone(), + class.clone(), + round.clone(), + frequencies.clone(), + label.clone(), + ); + } + } + } + out +} + +#[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(), + class: None, + round: None, + frequencies: vec![], + label: None, + } + } + + 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, + heat: None, + }) + } + + /// A pass STAMPED for `heat` (the bridge's tag-attribution path). + fn tagged_pass(heat: &str, 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, + heat: Some(HeatId(heat.into())), + }) + } + + fn registered(competitor: &str, pilot: &str) -> Event { + Event::CompetitorRegistered { + adapter: AdapterId("vd".into()), + competitor: CompetitorRef(competitor.into()), + pilot: PilotId(pilot.into()), + } + } + + #[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_final() { + // Scheduled → Staged → Armed → Running → Unofficial → Final. + let steps = [ + (HeatTransition::Staged, HeatPhase::Staged), + (HeatTransition::Armed, HeatPhase::Armed), + (HeatTransition::Running, HeatPhase::Running), + (HeatTransition::Finished, HeatPhase::Unofficial), + (HeatTransition::Finalized, HeatPhase::Final), + ]; + 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_defaults_to_the_first_scheduled_heat() { + // Before any transition or selection, the first scheduled heat is the controllable one + // even when a later heat was scheduled afterwards (filling adds, it does not steal focus). + let events = vec![scheduled("q-1", &["A", "B"]), scheduled("q-2", &["C", "D"])]; + let s = live_state(&events); + assert_eq!(s.current_heat, Some(HeatId("q-1".into()))); + assert_eq!( + s.active_pilots, + vec![CompetitorRef("A".into()), CompetitorRef("B".into())] + ); + } + + #[test] + fn freshly_scheduled_heat_does_not_steal_focus() { + // q-1 is staged (it's the active heat). Filling q-2 (a bare HeatScheduled) must NOT move + // the current heat — focus only moves on a real transition or an explicit selection. + let events = vec![ + scheduled("q-1", &["A", "B"]), + changed("q-1", HeatTransition::Staged), + scheduled("q-2", &["C", "D"]), + ]; + let s = live_state(&events); + assert_eq!(s.current_heat, Some(HeatId("q-1".into()))); + assert_eq!(s.phase, HeatPhase::Staged); + // q-2 is on deck, not current. + assert_eq!(s.on_deck, Some(HeatId("q-2".into()))); + } + + #[test] + fn current_heat_follows_a_heat_state_change() { + // q-1 runs and scores; q-2 is scheduled (no focus steal) then transitioned (Staged), + // which moves the current heat to q-2. + 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::Finalized), + scheduled("q-2", &["C", "D"]), + ]; + // After the bare schedule, focus is still on q-1 (last transition). + assert_eq!(live_state(&events).current_heat, Some(HeatId("q-1".into()))); + // A real transition on q-2 moves focus to it. + let mut events = events; + events.push(changed("q-2", HeatTransition::Staged)); + let s = live_state(&events); + assert_eq!(s.current_heat, Some(HeatId("q-2".into()))); + assert_eq!(s.phase, HeatPhase::Staged); + assert_eq!( + s.active_pilots, + vec![CompetitorRef("C".into()), CompetitorRef("D".into())] + ); + } + + #[test] + fn current_heat_follows_an_explicit_selection() { + // q-1 is active (staged); the RD manually selects q-2 in Live control. The current heat + // follows the explicit CurrentHeatSelected even though q-2 has not transitioned. + let events = vec![ + scheduled("q-1", &["A", "B"]), + changed("q-1", HeatTransition::Staged), + scheduled("q-2", &["C", "D"]), + Event::CurrentHeatSelected { + heat: HeatId("q-2".into()), + }, + ]; + 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())] + ); + // A later selection back to q-1 follows again (last selection wins). + let mut events = events; + events.push(Event::CurrentHeatSelected { + heat: HeatId("q-1".into()), + }); + assert_eq!(live_state(&events).current_heat, Some(HeatId("q-1".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 on_deck_is_the_heat_after_current_not_an_earlier_scheduled_one() { + // The exact bug: the current heat (q-2) is mid-order, and an EARLIER heat (q-1) is still + // Scheduled (never staged/run). On-deck — and therefore Advance — must pick the heat + // *after* q-2 (q-3), NOT roll backward to the earlier still-scheduled q-1. + let events = vec![ + scheduled("q-1", &["A", "B"]), + scheduled("q-2", &["C", "D"]), + scheduled("q-3", &["E", "F"]), + // q-2 is the current heat (it's running); q-1 was skipped and stays Scheduled. + Event::CurrentHeatSelected { + heat: HeatId("q-2".into()), + }, + changed("q-2", HeatTransition::Staged), + changed("q-2", HeatTransition::Armed), + changed("q-2", HeatTransition::Running), + ]; + let s = live_state(&events); + assert_eq!(s.current_heat, Some(HeatId("q-2".into()))); + // Forward, not backward: q-3 (after current), never q-1 (earlier-scheduled). + assert_eq!(s.on_deck, Some(HeatId("q-3".into()))); + } + + #[test] + fn on_deck_falls_back_to_an_earlier_scheduled_heat_when_current_is_last() { + // The current heat (q-3) is last in schedule order, so there is no scheduled heat after + // it. Rather than getting stuck, on-deck falls back to the first still-Scheduled heat + // overall (q-1, which was skipped) so the RD can still advance to it. + let events = vec![ + scheduled("q-1", &["A", "B"]), + scheduled("q-2", &["C", "D"]), + scheduled("q-3", &["E", "F"]), + // q-2 already ran and finalized; q-1 was skipped (stays Scheduled). + changed("q-2", HeatTransition::Staged), + changed("q-2", HeatTransition::Armed), + changed("q-2", HeatTransition::Running), + changed("q-2", HeatTransition::Finished), + changed("q-2", HeatTransition::Finalized), + // q-3 is now the current heat and running (it's last in order). + changed("q-3", HeatTransition::Staged), + changed("q-3", HeatTransition::Armed), + changed("q-3", HeatTransition::Running), + ]; + let s = live_state(&events); + assert_eq!(s.current_heat, Some(HeatId("q-3".into()))); + // No scheduled heat after q-3 → fall back to the first still-Scheduled heat overall. + assert_eq!(s.on_deck, Some(HeatId("q-1".into()))); + } + + #[test] + fn registered_competitor_surfaces_its_pilot_unregistered_stays_bare() { + // A is bound to a pilot; B is never registered. A's progress carries the pilot; + // B's pilot stays `None` (it appears by its bare CompetitorRef). + let events = vec![ + scheduled("q-1", &["A", "B"]), + registered("A", "acroace"), + changed("q-1", HeatTransition::Running), + ]; + let s = live_state(&events); + let a = s + .progress + .iter() + .find(|p| p.competitor == CompetitorRef("A".into())) + .unwrap(); + assert_eq!(a.pilot, Some(PilotId("acroace".into()))); + let b = s + .progress + .iter() + .find(|p| p.competitor == CompetitorRef("B".into())) + .unwrap(); + assert_eq!(b.pilot, None); + } + + #[test] + fn last_registration_wins_when_a_competitor_is_rebound() { + // A is bound to acroace, then re-bound to zoomer: the live state shows zoomer. + let events = vec![ + scheduled("q-1", &["A"]), + registered("A", "acroace"), + registered("A", "zoomer"), + ]; + let s = live_state(&events); + let a = &s.progress[0]; + assert_eq!(a.pilot, Some(PilotId("zoomer".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)); + } + + #[test] + fn abort_resets_live_lap_count_to_zero() { + // A heat runs and banks laps, then is aborted (→ Scheduled, no new run yet). The aborted + // run's passes stay in the log, but the live count must be 0 for every pilot — `progress` + // (Leaderboard / heat sheet) and `running_order` reset together. + let mut 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("A", 4_000_000, 2), // A: 1 lap before the abort + pass("B", 1_500_000, 1), + ]; + // Before the abort, A has a lap. + let pre = live_state(&events); + assert_eq!( + pre.progress + .iter() + .find(|p| p.competitor == CompetitorRef("A".into())) + .unwrap() + .laps_completed, + 1 + ); + + // Abort: heat back to Scheduled, the prior run's passes linger in the log. + events.push(changed("q-1", HeatTransition::Aborted)); + let s = live_state(&events); + assert_eq!(s.phase, HeatPhase::Scheduled); + assert!( + s.progress.iter().all(|p| p.laps_completed == 0), + "every pilot's live lap count resets to 0 after Abort" + ); + assert!( + s.progress.iter().all(|p| p.last_lap_micros.is_none()), + "the stale last-lap also clears after Abort" + ); + } + + #[test] + fn discard_resets_live_lap_count_to_zero() { + // Discard is a reset like Abort/Restart (Unofficial/Final → Scheduled, result thrown + // away): the discarded run's passes stay in the log, but the live count must be 0 — the + // overnight soak caught a discarded heat still showing the dead run's laps. + 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("A", 4_000_000, 2), // A: 1 lap before the discard + pass("B", 1_500_000, 1), + changed("q-1", HeatTransition::Finished), + changed("q-1", HeatTransition::Discarded), + ]; + let s = live_state(&events); + assert_eq!(s.phase, HeatPhase::Scheduled); + assert!( + s.progress.iter().all(|p| p.laps_completed == 0), + "every pilot's live lap count resets to 0 after Discard" + ); + assert!( + s.progress.iter().all(|p| p.last_lap_micros.is_none()), + "the stale last-lap also clears after Discard" + ); + } + + #[test] + fn tagged_passes_survive_a_mid_race_heat_schedule() { + // The scheduling-eats-laps bug: a HeatScheduled for ANOTHER heat lands mid-race + // (an RD filling the next round), which closes the running heat's positional span. + // Tagged passes attribute by their stamp, so the laps after it still count. + let events = vec![ + scheduled("q-1", &["A"]), + changed("q-1", HeatTransition::Running), + tagged_pass("q-1", "A", 1_000_000, 1), + tagged_pass("q-1", "A", 3_000_000, 2), // lap 1 banked + scheduled("q-2", &["B"]), // mid-race schedule (span closer) + tagged_pass("q-1", "A", 5_000_000, 3), // lap 2 — used to vanish + ]; + let s = live_state(&events); + assert_eq!(s.current_heat, Some(HeatId("q-1".into()))); + let a = s + .progress + .iter() + .find(|p| p.competitor == CompetitorRef("A".into())) + .unwrap(); + assert_eq!( + a.laps_completed, 2, + "the post-schedule lap must still count for the running heat" + ); + } + + #[test] + fn tagged_passes_do_not_leak_into_an_older_selected_heat() { + // Heats race back to back; the RD selects the FINISHED first heat as current. + // Its live view must show ITS laps only — the later heat's tagged passes all sit + // after q-1's run_start, so the positional rule alone absorbed them. + let events = vec![ + scheduled("q-1", &["A"]), + changed("q-1", HeatTransition::Running), + tagged_pass("q-1", "A", 1_000_000, 1), + tagged_pass("q-1", "A", 3_000_000, 2), // q-1: 1 lap + changed("q-1", HeatTransition::Finished), + scheduled("q-2", &["A"]), + changed("q-2", HeatTransition::Running), + tagged_pass("q-2", "A", 11_000_000, 3), + tagged_pass("q-2", "A", 13_000_000, 4), + tagged_pass("q-2", "A", 15_000_000, 5), // q-2: 2 laps + changed("q-2", HeatTransition::Finished), + Event::CurrentHeatSelected { + heat: HeatId("q-1".into()), + }, + ]; + let s = live_state(&events); + assert_eq!(s.current_heat, Some(HeatId("q-1".into()))); + let a = s + .progress + .iter() + .find(|p| p.competitor == CompetitorRef("A".into())) + .unwrap(); + assert_eq!(a.laps_completed, 1, "only q-1's own lap counts"); + } + + #[test] + fn running_order_ties_break_on_last_lap_completion_not_duration() { + // A completes lap 2 at t=20s (a slow 10s lap); B completes lap 2 at t=33s (a fast + // 8s lap). A is physically ahead — the scorer ranks by earlier completion, and the + // live order must agree (it used to rank B first on the shorter duration). + let events = vec![ + scheduled("q-1", &["A", "B"]), + changed("q-1", HeatTransition::Running), + pass("A", 0, 1), + pass("B", 5_000_000, 1), + pass("A", 10_000_000, 2), // A lap 1 @10s + pass("B", 25_000_000, 2), // B lap 1 @25s (20s lap) + pass("A", 20_000_000, 3), // A lap 2 @20s (10s lap) + pass("B", 33_000_000, 3), // B lap 2 @33s (8s lap) + ]; + let s = live_state(&events); + assert_eq!( + s.running_order, + vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + "equal laps: the EARLIER last completion leads" + ); + } + + #[test] + fn rerun_after_abort_counts_from_zero_not_old_plus_new() { + // After an abort, a fresh re-run (re-Stage → Start → new passes) counts ONLY the new run's + // laps — the aborted run's passes are excluded, so it is N-from-zero, never old+new. + let events = vec![ + scheduled("q-1", &["A", "B"]), + changed("q-1", HeatTransition::Staged), + changed("q-1", HeatTransition::Armed), + changed("q-1", HeatTransition::Running), + // The aborted run: A banks 2 laps (3 passes), B banks 1 lap. + pass("A", 1_000_000, 1), + pass("A", 4_000_000, 2), + pass("A", 7_000_000, 3), + pass("B", 1_500_000, 1), + pass("B", 4_500_000, 2), + changed("q-1", HeatTransition::Aborted), + // The fresh re-run: A banks 1 lap (2 passes), B none. + changed("q-1", HeatTransition::Staged), + changed("q-1", HeatTransition::Armed), + changed("q-1", HeatTransition::Running), + pass("A", 20_000_000, 4), + pass("A", 22_500_000, 5), // new lap = 2.5s + ]; + let s = live_state(&events); + assert_eq!(s.phase, HeatPhase::Running); + let a = s + .progress + .iter() + .find(|p| p.competitor == CompetitorRef("A".into())) + .unwrap(); + // 1 (this run), NOT 2 (aborted run) + 1 = 3. + assert_eq!(a.laps_completed, 1); + 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, 0, "B's aborted-run lap is excluded"); + // Running order ranks by the current run: A (1 lap) leads B (0). + assert_eq!( + s.running_order, + vec![CompetitorRef("A".into()), CompetitorRef("B".into())] + ); + } + + #[test] + fn restart_resets_live_lap_count_to_zero_and_reruns_from_zero() { + // Same as Abort but via Restart (a committed heat restarted → Scheduled). After Restart the + // count is 0; the re-run counts from zero. + let mut 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("A", 4_000_000, 2), // A: 1 lap + changed("q-1", HeatTransition::Restarted), + ]; + let s = live_state(&events); + assert_eq!(s.phase, HeatPhase::Scheduled); + assert!( + s.progress.iter().all(|p| p.laps_completed == 0), + "every pilot's live lap count resets to 0 after Restart" + ); + + // Re-run: A banks 2 laps from zero. + events.extend([ + changed("q-1", HeatTransition::Staged), + changed("q-1", HeatTransition::Armed), + changed("q-1", HeatTransition::Running), + pass("A", 30_000_000, 3), + pass("A", 33_000_000, 4), + pass("A", 36_000_000, 5), + ]); + let s = live_state(&events); + let a = s + .progress + .iter() + .find(|p| p.competitor == CompetitorRef("A".into())) + .unwrap(); + assert_eq!(a.laps_completed, 2, "counted from zero, not 1 + 2"); + } + + #[test] + fn normal_finalize_without_a_reset_counts_the_whole_heat() { + // A heat that finalizes normally was never reset → the run begins at its Running, so the + // whole run's laps count (finalized results are unaffected by the run-scoping). + let events = vec![ + scheduled("q-1", &["A", "B"]), // 0 + changed("q-1", HeatTransition::Staged), // 1 + changed("q-1", HeatTransition::Armed), // 2 + changed("q-1", HeatTransition::Running), // 3 — the run starts here + pass("A", 1_000_000, 1), + pass("A", 4_000_000, 2), + pass("A", 7_000_000, 3), // A: 2 laps + changed("q-1", HeatTransition::Finished), + changed("q-1", HeatTransition::Finalized), + ]; + assert_eq!(current_run_start(&events, &heat()), 3); + let s = live_state(&events); + assert_eq!(s.phase, HeatPhase::Final); + let a = s + .progress + .iter() + .find(|p| p.competitor == CompetitorRef("A".into())) + .unwrap(); + assert_eq!( + a.laps_completed, 2, + "a normally-finalized heat counts every lap" + ); + } + + #[test] + fn current_run_start_finds_the_latest_reset_boundary() { + // Two aborts: the boundary is one past the *latest* one. A reset on a *different* heat does + // not move this heat's boundary. + let events = vec![ + scheduled("q-1", &["A"]), // 0 + changed("q-1", HeatTransition::Running), // 1 + pass("A", 1_000_000, 1), // 2 + changed("q-1", HeatTransition::Aborted), // 3 + scheduled("q-2", &["B"]), // 4 + changed("q-2", HeatTransition::Aborted), // 5 — a DIFFERENT heat's reset + changed("q-1", HeatTransition::Running), // 6 + pass("A", 2_000_000, 2), // 7 + changed("q-1", HeatTransition::Restarted), // 8 — q-1's latest reset + changed("q-1", HeatTransition::Running), // 9 + ]; + // q-1's run starts at its latest Running (9) — after the latest restart (8). + assert_eq!(current_run_start(&events, &HeatId("q-1".into())), 9); + // A heat that has not run yet (Scheduled, no Running/reset) windows past the end — no + // current-run passes, so the live count is zero (never a prior-heat total). + let only_scheduled = [scheduled("q-9", &["Z"])]; + assert_eq!( + current_run_start(&only_scheduled, &HeatId("q-9".into())), + only_scheduled.len() + ); + } + + #[test] + fn current_run_start_scopes_to_this_heats_run_not_earlier_heats() { + // A flies q-1 (two laps), then flies q-2 (one lap): q-2's run begins at its own Running, so + // q-1's crossings are excluded — the live count is this heat's run, not a cross-heat total. + let events = vec![ + scheduled("q-1", &["A"]), // 0 + changed("q-1", HeatTransition::Running), // 1 + pass("A", 1_000_000, 1), // 2 — q-1 holeshot + pass("A", 2_000_000, 2), // 3 — q-1 lap 1 + pass("A", 3_000_000, 3), // 4 — q-1 lap 2 + scheduled("q-2", &["A"]), // 5 + changed("q-2", HeatTransition::Running), // 6 + pass("A", 10_000_000, 1), // 7 — q-2 holeshot + pass("A", 13_000_000, 2), // 8 — q-2 lap 1 + ]; + // Before the fix this was 0 (the whole log), so q-2's live count summed in q-1's laps. + assert_eq!(current_run_start(&events, &HeatId("q-2".into())), 6); + // End to end: the current heat (q-2) shows A with one lap — its q-2 run only, not three. + let a = live_state(&events) + .progress + .into_iter() + .find(|p| p.competitor == CompetitorRef("A".into())) + .expect("A is in the current heat"); + assert_eq!( + a.laps_completed, 1, + "q-2 live count excludes A's earlier q-1 laps" + ); + } + + #[test] + fn run_scoped_lap_count_is_deterministic_on_replay() { + let events = vec![ + scheduled("q-1", &["A"]), + changed("q-1", HeatTransition::Running), + pass("A", 1_000_000, 1), + pass("A", 4_000_000, 2), + changed("q-1", HeatTransition::Aborted), + changed("q-1", HeatTransition::Running), + pass("A", 9_000_000, 3), + ]; + assert_eq!(live_state(&events), live_state(&events)); + } + + fn scheduled_tagged(id: &str, lineup: &[&str], class: &str, round: &str) -> Event { + Event::HeatScheduled { + heat: HeatId(id.into()), + lineup: lineup.iter().map(|c| CompetitorRef((*c).into())).collect(), + class: Some(ClassId(class.into())), + round: Some(RoundId(round.into())), + frequencies: vec![], + label: None, + } + } + + #[test] + fn heat_summaries_lists_each_heat_with_tag_phase_and_current() { + // q-1 (round r1) ran and scored; q-2 (round r1) is scheduled and current; q-x is an + // untagged free-text heat that still appears (no round/class). + let events = vec![ + scheduled_tagged("q-1", &["A", "B"], "open", "r1"), + changed("q-1", HeatTransition::Staged), + changed("q-1", HeatTransition::Armed), + changed("q-1", HeatTransition::Running), + changed("q-1", HeatTransition::Finished), + changed("q-1", HeatTransition::Finalized), + scheduled_tagged("q-2", &["C", "D"], "open", "r1"), + scheduled("q-x", &["E"]), + ]; + // q-1's last event is a transition (Finalized); q-2 and q-x are bare schedules that don't + // steal focus — so the current heat is q-1 (its `Final` heat stays on the timer between + // heats, "last heat, now scored"), not the freshly-scheduled q-x. + let summaries = heat_summaries(&events); + assert_eq!(summaries.len(), 3); + + // First-scheduled order is preserved. + assert_eq!( + summaries + .iter() + .map(|h| h.heat.0.clone()) + .collect::>(), + vec!["q-1", "q-2", "q-x"] + ); + + let q1 = &summaries[0]; + assert_eq!(q1.round, Some(RoundId("r1".into()))); + assert_eq!(q1.class, Some(ClassId("open".into()))); + assert_eq!(q1.phase, HeatPhase::Final); + assert!(q1.is_current); + assert_eq!( + q1.lineup, + vec![CompetitorRef("A".into()), CompetitorRef("B".into())] + ); + + let q2 = &summaries[1]; + assert_eq!(q2.round, Some(RoundId("r1".into()))); + assert_eq!(q2.phase, HeatPhase::Scheduled); + assert!(!q2.is_current); + + let qx = &summaries[2]; + assert_eq!(qx.round, None); + assert_eq!(qx.class, None); + assert!(!qx.is_current); + } + + #[test] + fn heat_summaries_empty_log_is_empty() { + assert!(heat_summaries(&[]).is_empty()); + } + + /// Wrap an event as a stored log entry with an explicit `recorded_at` (µs). + fn stored_at(event: Event, recorded_at: i64) -> StoredEvent { + StoredEvent { + offset: 0, + recorded_at: Some(recorded_at), + event, + } + } + + /// Wrap an event as a stored log entry with no `recorded_at` (an older / untimed entry). + fn stored(event: Event) -> StoredEvent { + StoredEvent { + offset: 0, + recorded_at: None, + event, + } + } + + #[test] + fn heat_timing_folds_from_running_and_finished_transitions() { + // The race-start is the `Running` transition's `recorded_at`; the race-end is the + // `Finished` (→ Unofficial) transition's. Other transitions carry no timing. + let log = vec![ + stored(scheduled("q-1", &["A", "B"])), + stored_at(changed("q-1", HeatTransition::Staged), 100), + stored_at(changed("q-1", HeatTransition::Armed), 200), + stored_at(changed("q-1", HeatTransition::Running), 1_000_000), + stored_at(changed("q-1", HeatTransition::Finished), 61_000_000), + ]; + let (started, ended) = heat_timing(&log, &HeatId("q-1".into())); + assert_eq!(started, Some(1_000_000)); + assert_eq!(ended, Some(61_000_000)); + // Exactly a 60s duration — the value a frozen clock reads. + assert_eq!(ended.unwrap() - started.unwrap(), 60_000_000); + } + + #[test] + fn heat_timing_running_only_has_no_end_yet() { + let log = vec![ + stored(scheduled("q-1", &["A"])), + stored_at(changed("q-1", HeatTransition::Running), 5_000_000), + ]; + let (started, ended) = heat_timing(&log, &HeatId("q-1".into())); + assert_eq!(started, Some(5_000_000)); + assert_eq!(ended, None); + } + + #[test] + fn heat_timing_rerun_after_abort_restamps_start_and_clears_end() { + // A run, an abort, then a fresh run: the latest `Running` is the start and the + // earlier end is cleared (the new run has not finished). + let log = vec![ + stored(scheduled("q-1", &["A"])), + stored_at(changed("q-1", HeatTransition::Running), 1_000_000), + stored_at(changed("q-1", HeatTransition::Finished), 2_000_000), + stored_at(changed("q-1", HeatTransition::Aborted), 2_500_000), + stored_at(changed("q-1", HeatTransition::Running), 9_000_000), + ]; + let (started, ended) = heat_timing(&log, &HeatId("q-1".into())); + assert_eq!(started, Some(9_000_000)); + assert_eq!(ended, None); + } + + #[test] + fn with_heat_timing_sets_the_current_heats_instants() { + let log = vec![ + stored(scheduled("q-1", &["A", "B"])), + stored_at(changed("q-1", HeatTransition::Running), 7_000_000), + stored_at(changed("q-1", HeatTransition::Finished), 67_000_000), + ]; + let events: Vec = log.iter().map(|s| s.event.clone()).collect(); + let live = with_heat_timing(live_state(&events), &log); + assert_eq!(live.current_heat, Some(HeatId("q-1".into()))); + assert_eq!(live.race_started_at, Some(7_000_000)); + assert_eq!(live.race_ended_at, Some(67_000_000)); + } + + #[test] + fn with_heat_timing_no_current_heat_leaves_timing_none() { + let live = with_heat_timing(live_state(&[]), &[]); + assert_eq!(live.race_started_at, None); + assert_eq!(live.race_ended_at, None); + assert_eq!(live.lifecycle, None); + } + + // --- the start-tone countdown anchor (RD-only tone countdown while Armed) ----------- + + fn starting(id: &str, delay_ms: u32) -> Event { + Event::HeatStarting { + heat: HeatId(id.into()), + delay_ms, + } + } + + #[test] + fn tone_at_is_armed_recorded_instant_plus_delay() { + // The heat armed at t=1_000_000µs with a 3000ms (3s) hold ⇒ the tone fires at 4_000_000µs. + let log = vec![ + stored(scheduled("q-1", &["A", "B"])), + stored_at(changed("q-1", HeatTransition::Staged), 500_000), + stored_at(changed("q-1", HeatTransition::Armed), 1_000_000), + stored_at(starting("q-1", 3000), 1_000_000), + ]; + let events: Vec = log.iter().map(|s| s.event.clone()).collect(); + let live = with_heat_timing(live_state(&events), &log); + assert_eq!(live.phase, HeatPhase::Armed); + assert_eq!(live.tone_at, Some(4_000_000)); + } + + #[test] + fn tone_at_is_cleared_once_the_heat_is_running() { + // Once the heat advances to Running the tone has fired; `race_started_at` is the anchor now, + // so `tone_at` is `None` (no stale countdown for a late join after race-go). + let log = vec![ + stored(scheduled("q-1", &["A"])), + stored_at(starting("q-1", 3000), 1_000_000), + stored_at(changed("q-1", HeatTransition::Running), 4_000_000), + ]; + let events: Vec = log.iter().map(|s| s.event.clone()).collect(); + let live = with_heat_timing(live_state(&events), &log); + assert_eq!(live.phase, HeatPhase::Running); + assert_eq!(live.tone_at, None); + assert_eq!(live.race_started_at, Some(4_000_000)); + } + + #[test] + fn tone_at_is_none_before_the_heat_arms() { + // A merely-staged heat (no HeatStarting yet) has no tone instant. + let log = vec![ + stored(scheduled("q-1", &["A"])), + stored_at(changed("q-1", HeatTransition::Staged), 500_000), + ]; + let events: Vec = log.iter().map(|s| s.event.clone()).collect(); + let live = with_heat_timing(live_state(&events), &log); + assert_eq!(live.phase, HeatPhase::Staged); + assert_eq!(live.tone_at, None); + } + + #[test] + fn tone_at_uses_the_latest_starting_on_a_re_arm() { + // The heat armed, was aborted back, and re-armed: the latest `HeatStarting` wins (a fresh + // random hold). The `Running` between them (the abort path never reaches Running here) does + // not apply; the second arming's instant + delay is the live tone. + let log = vec![ + stored(scheduled("q-1", &["A"])), + stored_at(starting("q-1", 2000), 1_000_000), + stored_at(changed("q-1", HeatTransition::Aborted), 1_500_000), + stored_at(changed("q-1", HeatTransition::Staged), 2_000_000), + stored_at(changed("q-1", HeatTransition::Armed), 2_500_000), + stored_at(starting("q-1", 5000), 2_500_000), + ]; + let events: Vec = log.iter().map(|s| s.event.clone()).collect(); + let live = with_heat_timing(live_state(&events), &log); + assert_eq!(live.phase, HeatPhase::Armed); + // 2_500_000µs armed + 5000ms hold = 7_500_000µs. + assert_eq!(live.tone_at, Some(7_500_000)); + } + + #[test] + fn tone_at_is_none_for_an_untimed_starting_entry() { + // An older / untimed `HeatStarting` (no `recorded_at`) yields no absolute instant. + let log = vec![ + stored(scheduled("q-1", &["A"])), + stored_at(changed("q-1", HeatTransition::Armed), 1_000_000), + stored(starting("q-1", 3000)), + ]; + let events: Vec = log.iter().map(|s| s.event.clone()).collect(); + let live = with_heat_timing(live_state(&events), &log); + assert_eq!(live.phase, HeatPhase::Armed); + assert_eq!(live.tone_at, None); + } + + #[test] + fn tone_at_folds_deterministically() { + let log = vec![ + stored(scheduled("q-1", &["A"])), + stored_at(changed("q-1", HeatTransition::Armed), 1_000_000), + stored_at(starting("q-1", 3000), 1_000_000), + ]; + let events: Vec = log.iter().map(|s| s.event.clone()).collect(); + let a = with_heat_timing(live_state(&events), &log); + let b = with_heat_timing(live_state(&events), &log); + assert_eq!(a.tone_at, b.tone_at); + } + + // --- the provisional → official lifecycle (marshaling Slice 5) ------------------- + + fn finalizing(id: &str, at: i64) -> Event { + Event::HeatFinalizing { + heat: HeatId(id.into()), + at, + } + } + + #[test] + fn lifecycle_is_none_before_the_heat_is_unofficial() { + // A running heat has no result yet — no provisional/official lifecycle to show. + let log = vec![ + stored(scheduled("q-1", &["A"])), + stored_at(changed("q-1", HeatTransition::Running), 1_000_000), + ]; + let events: Vec = log.iter().map(|s| s.event.clone()).collect(); + let live = with_heat_timing(live_state(&events), &log); + assert_eq!(live.lifecycle, None); + } + + #[test] + fn unofficial_without_a_protest_window_is_provisional_manual() { + // No `HeatFinalizing` logged ⇒ manual finalize only: Provisional with no auto-official deadline. + let log = vec![ + stored(scheduled("q-1", &["A"])), + stored_at(changed("q-1", HeatTransition::Running), 1_000_000), + stored_at(changed("q-1", HeatTransition::Finished), 2_000_000), + ]; + let events: Vec = log.iter().map(|s| s.event.clone()).collect(); + let live = with_heat_timing(live_state(&events), &log); + assert_eq!( + live.lifecycle, + Some(LifecycleState::Provisional { + auto_official_at: None + }) + ); + } + + #[test] + fn unofficial_with_a_protest_window_carries_the_auto_official_deadline() { + // The runtime logged a deadline ⇒ Provisional with the auto-official instant for the countdown. + let log = vec![ + stored(scheduled("q-1", &["A"])), + stored_at(changed("q-1", HeatTransition::Running), 1_000_000), + stored_at(changed("q-1", HeatTransition::Finished), 2_000_000), + stored_at(finalizing("q-1", 122_000_000), 2_000_100), + ]; + let events: Vec = log.iter().map(|s| s.event.clone()).collect(); + let live = with_heat_timing(live_state(&events), &log); + assert_eq!( + live.lifecycle, + Some(LifecycleState::Provisional { + auto_official_at: Some(122_000_000) + }) + ); + } + + #[test] + fn final_heat_is_official() { + let log = vec![ + stored(scheduled("q-1", &["A"])), + stored_at(changed("q-1", HeatTransition::Running), 1_000_000), + stored_at(changed("q-1", HeatTransition::Finished), 2_000_000), + stored_at(changed("q-1", HeatTransition::Finalized), 3_000_000), + ]; + let events: Vec = log.iter().map(|s| s.event.clone()).collect(); + let live = with_heat_timing(live_state(&events), &log); + assert_eq!(live.lifecycle, Some(LifecycleState::Official)); + } + + #[test] + fn revert_re_opens_provisional() { + // Finalize, then Revert back to Unofficial: the heat is Provisional again (the lifecycle + // tracks the FSM, so a reverted result is correctable once more). The runtime re-arms the + // auto-official timer by logging a fresh `HeatFinalizing`; here we just assert it re-opened. + let log = vec![ + stored(scheduled("q-1", &["A"])), + stored_at(changed("q-1", HeatTransition::Running), 1_000_000), + stored_at(changed("q-1", HeatTransition::Finished), 2_000_000), + stored_at(changed("q-1", HeatTransition::Finalized), 3_000_000), + stored_at(changed("q-1", HeatTransition::Reverted), 4_000_000), + ]; + let events: Vec = log.iter().map(|s| s.event.clone()).collect(); + let live = with_heat_timing(live_state(&events), &log); + assert_eq!( + live.lifecycle, + Some(LifecycleState::Provisional { + auto_official_at: None + }) + ); + } + + #[test] + fn a_fresh_run_drops_a_stale_auto_official_deadline() { + // A heat finished + armed a window, then was restarted and re-run: the new run's window has + // not been armed yet, so the stale deadline from the previous run is cleared on the `Running`. + let log = vec![ + stored(scheduled("q-1", &["A"])), + stored_at(changed("q-1", HeatTransition::Running), 1_000_000), + stored_at(changed("q-1", HeatTransition::Finished), 2_000_000), + stored_at(finalizing("q-1", 122_000_000), 2_000_100), + stored_at(changed("q-1", HeatTransition::Restarted), 2_500_000), + stored_at(changed("q-1", HeatTransition::Running), 9_000_000), + stored_at(changed("q-1", HeatTransition::Finished), 10_000_000), + ]; + let events: Vec = log.iter().map(|s| s.event.clone()).collect(); + let live = with_heat_timing(live_state(&events), &log); + assert_eq!( + live.lifecycle, + Some(LifecycleState::Provisional { + auto_official_at: None + }) + ); + } + + #[test] + fn lifecycle_folds_deterministically() { + let log = vec![ + stored(scheduled("q-1", &["A"])), + stored_at(changed("q-1", HeatTransition::Running), 1_000_000), + stored_at(changed("q-1", HeatTransition::Finished), 2_000_000), + stored_at(finalizing("q-1", 122_000_000), 2_000_100), + ]; + let events: Vec = log.iter().map(|s| s.event.clone()).collect(); + let a = with_heat_timing(live_state(&events), &log); + let b = with_heat_timing(live_state(&events), &log); + assert_eq!(a.lifecycle, b.lifecycle); + } + + #[test] + fn heat_summaries_carry_the_frequency_assignment_and_default_empty() { + // Race redesign Slice 4b: a heat scheduled with per-pilot frequencies surfaces them on the + // summary (so the Heats UI can label them); a sim/free-text heat with none reads back empty. + let assigned = Event::HeatScheduled { + heat: HeatId("q-1".into()), + lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + class: None, + round: None, + frequencies: vec![ + (CompetitorRef("A".into()), 5658), + (CompetitorRef("B".into()), 5800), + ], + label: None, + }; + let summaries = heat_summaries(&[assigned, scheduled("q-2", &["C"])]); + assert_eq!( + summaries[0].frequencies, + vec![ + (CompetitorRef("A".into()), 5658), + (CompetitorRef("B".into()), 5800), + ] + ); + assert!( + summaries[1].frequencies.is_empty(), + "a heat scheduled with no frequencies has an empty assignment" + ); + } +} diff --git a/crates/server/src/open_practice.rs b/crates/server/src/open_practice.rs new file mode 100644 index 0000000..071c54a --- /dev/null +++ b/crates/server/src/open_practice.rs @@ -0,0 +1,449 @@ +//! The **open-practice live accumulator** (open-practice format, Slice 1) — the per-channel, +//! **in-memory (NOT logged)** lap store and its live delivery onto the existing `/stream`. +//! +//! # Why this exists — laps that are never logged +//! +//! Open practice is casual: a round is one open heat over the active **channels** (`node-{i}` +//! seats), and each channel's laps are shown live but **deliberately not recorded**. The session +//! itself *is* recorded (the heat's `HeatScheduled` + its start/stop `HeatStateChanged` are logged), +//! but the practice *passes* are not — so the durable log carries the session boundaries and zero +//! `Pass` events for an open-practice heat. The source bridge therefore routes an open-practice +//! heat's timer passes **here**, into [`OpenPracticeLive`], instead of `AppState::append`. +//! +//! # Live delivery — the **real log's** phase/clock, overlaid with the per-channel laps +//! +//! The live-state fold is normally a pure fold of the log; the non-logged practice laps can't drive +//! it through that fold. Earlier this accumulator served a **fully synthetic** [`LiveRaceState`] +//! (its own `Running` start plus a shadow-recorded transition list) *in place of* the log fold — +//! but that synthetic phase/clock **drifted** from the real heat: a `Restart` resetting the heat to +//! `Scheduled` was never reflected (so the console kept rendering `Unofficial`/Final buttons and +//! `Restart` then errored), and re-computing the synthetic slice reset the clock basis (the +//! ~0.104 s clock bump after the time limit fired). +//! +//! So the overlay is now **laps-only**, and phase/clock are authoritative from the log. The +//! live-state fold computes the [`LiveRaceState`] from the **real log** exactly as for any heat +//! (the real `HeatScheduled` + the real `HeatStateChanged` transitions → truthful `current_heat`, +//! `phase`, lineup, on-deck), then calls [`OpenPracticeLive::merge_into`] to splice the +//! accumulator's per-channel laps into that base when its active heat *is* the current heat. The +//! phase/clock therefore **cannot drift** — they are always the log's — while the non-logged laps +//! still show. Every accumulator mutation **and every clear** wakes the same append-notify +//! ([`AppState::appended`](crate::app::AppState)) a real `append` would, so a parked stream re-folds +//! and pushes a fresh-value envelope; when the accumulator clears, that re-fold is overlay-free and +//! the console immediately settles back onto the bare log state (no stale frame). +//! +//! # The laps come from the same projection (no second lap definition) +//! +//! The per-channel laps are derived by feeding a synthetic event slice — the heat's `HeatScheduled` +//! over the active channels plus the accumulated lap-gate passes — straight to +//! [`live_state`](crate::live_state::live_state). So consecutive passes become laps via the exact +//! same fold the logged path uses; a channel is just an unbound competitor (`node-{i}`), so its +//! `PilotProgress.pilot` is naturally `None`. Only the per-channel `progress` is read off that +//! slice; the slice's own phase is ignored (the served phase is the log's). + +use std::sync::{Arc, RwLock}; + +use gridfpv_events::{CompetitorRef, Event, HeatId, HeatTransition, Pass}; + +use crate::live_state::{LiveRaceState, PilotProgress, live_state}; + +/// One open-practice heat's **in-memory** state: the active heat, its channel lineup, and the +/// accumulated lap-gate passes (never logged). +#[derive(Debug, Clone)] +struct ActivePractice { + /// The open-practice heat currently accumulating laps. + heat: HeatId, + /// The active channels as `node-{i}` competitor refs, in node order — the heat's lineup. + channels: Vec, + /// The lap-gate passes seen for this heat so far, in arrival order. These are **not** appended + /// to the event log; they live only here and drive the live per-channel laps. + passes: Vec, +} + +impl ActivePractice { + /// Synthesize the event slice the lap fold consumes: the heat's `HeatScheduled` over the active + /// channels, a `Running` transition, then the accumulated passes. Reusing [`live_state`] over + /// this slice gives per-channel laps with `pilot: None` for free — the same fold the logged path + /// uses, no second lap definition. Only the resulting `progress` is read; the phase the slice + /// folds to is **ignored** (the served phase comes from the real log, not from here). + fn synthetic_events(&self) -> Vec { + let mut events = Vec::with_capacity(self.passes.len() + 2); + events.push(Event::HeatScheduled { + heat: self.heat.clone(), + lineup: self.channels.clone(), + class: None, + round: None, + frequencies: Vec::new(), + label: None, + }); + // A `Running` base so the lap fold treats the passes as in-heat laps. This is *only* for lap + // derivation; the phase served to clients is always the real log's. + events.push(Event::HeatStateChanged { + heat: self.heat.clone(), + transition: HeatTransition::Running, + }); + events.extend(self.passes.iter().cloned().map(Event::Pass)); + events + } + + /// The accumulator's per-channel live race-state: per-channel laps from the accumulated passes, + /// each channel an unbound competitor (`pilot: None`). Its phase is always `Running` (the + /// synthetic base) and is **not** what clients are served — only its [`progress`](LiveRaceState::progress) + /// is spliced into the log-authoritative base by [`merge_into`](OpenPracticeLive::merge_into). + fn live(&self) -> LiveRaceState { + live_state(&self.synthetic_events()) + } +} + +/// The shared **open-practice live accumulator** for one event (open-practice format, Slice 1). +/// +/// Holds the active open-practice heat's in-memory per-channel passes, or `None` when no +/// open-practice heat is active. Cloning shares the one cell (`Arc>`) between the source +/// bridge (which writes passes / starts / clears it) and the `/stream` live-state fold (which merges +/// its laps onto the log-authoritative state). One per event, alongside the event's +/// [`AppState`](crate::app::AppState). +#[derive(Clone, Default)] +pub struct OpenPracticeLive { + inner: Arc>>, +} + +impl OpenPracticeLive { + /// An accumulator with no active open-practice heat. + pub fn new() -> Self { + Self::default() + } + + /// **Begin** accumulating for an open-practice `heat` over `channels` (its `node-{i}` lineup), + /// clearing any prior open-practice state. Called when an open-practice heat goes `Running`. + pub fn begin(&self, heat: HeatId, channels: Vec) { + *self.write() = Some(ActivePractice { + heat, + channels, + passes: Vec::new(), + }); + } + + /// Record one lap-gate `pass` for the active open-practice heat (in memory, **not** logged). + /// + /// A no-op when there is no active open-practice heat (a stray pass after a clear). Returns + /// whether the pass was accepted, so the caller can wake `/stream` only when the live state + /// actually changed. + pub fn record(&self, pass: Pass) -> bool { + let mut guard = self.write(); + match guard.as_mut() { + Some(active) => { + active.passes.push(pass); + true + } + None => false, + } + } + + /// **Clear** the accumulator — drop all in-memory open-practice laps. Called when the + /// open-practice heat leaves `Running` (a terminal / abort / restart transition) or a new + /// heat/round takes over. Returns whether anything was actually cleared (so the caller wakes + /// `/stream` to re-fold the now-overlay-free log state only when needed). + pub fn clear(&self) -> bool { + let mut guard = self.write(); + if guard.is_some() { + *guard = None; + true + } else { + false + } + } + + /// Whether `heat` is the open-practice heat currently accumulating. + pub fn is_active(&self, heat: &HeatId) -> bool { + self.read().as_ref().map(|a| &a.heat) == Some(heat) + } + + /// The open-practice heat currently accumulating, if any. Used to decide whether a Heat-scope + /// fold should have the overlay laps spliced in. + pub fn active_heat(&self) -> Option { + self.read().as_ref().map(|a| a.heat.clone()) + } + + /// Splice the accumulator's per-channel laps into a **log-authoritative** `base` live state. + /// + /// `base` is the [`LiveRaceState`] folded from the real log — so its `current_heat`, `phase`, + /// lineup, running clock basis and on-deck are always truthful (a `Restart → Scheduled` is + /// reflected, the time-limit `Running → Unofficial` is reflected, and the clock basis never + /// resets). When the active open-practice heat **is** that current heat, this overlays the + /// non-logged per-channel lap counts / last-lap onto the matching `progress` rows and recomputes + /// the running order; otherwise (no active heat, or a different heat is current) `base` is + /// returned unchanged. Phase/clock therefore can never drift from the log — only the laps come + /// from the overlay. + pub fn merge_into(&self, mut base: LiveRaceState) -> LiveRaceState { + let guard = self.read(); + let Some(active) = guard.as_ref() else { + return base; + }; + // Only splice laps when the overlay's heat is the one the log says is current. Across a + // `Restart` (heat back to `Scheduled`) the bridge clears the accumulator, but guard anyway so a + // stale overlay can never bleed laps onto a different heat. + if base.current_heat.as_ref() != Some(&active.heat) { + return base; + } + + // Index the accumulator's per-channel laps by competitor. + let overlay = active.live(); + let laps_by_ref: std::collections::BTreeMap<&CompetitorRef, &PilotProgress> = overlay + .progress + .iter() + .map(|p| (&p.competitor, p)) + .collect(); + + for row in &mut base.progress { + if let Some(src) = laps_by_ref.get(&row.competitor) { + row.laps_completed = src.laps_completed; + row.last_lap_micros = src.last_lap_micros; + } + } + base.running_order = crate::live_state::running_order(&base.progress); + base + } + + /// The accumulator's standalone per-channel [`LiveRaceState`] (laps only; phase always + /// `Running`), or `None` when no open-practice heat is active. + /// + /// This is the accumulator's **internal** view — used by the bridge tests to observe the laps + /// filling. Clients are **not** served this directly; the served live state is the log fold with + /// these laps spliced in via [`merge_into`](Self::merge_into), so the served phase/clock is + /// always the log's. + pub fn live_state(&self) -> Option { + self.read().as_ref().map(ActivePractice::live) + } + + fn read(&self) -> std::sync::RwLockReadGuard<'_, Option> { + self.inner + .read() + .expect("open-practice accumulator poisoned") + } + + fn write(&self) -> std::sync::RwLockWriteGuard<'_, Option> { + self.inner + .write() + .expect("open-practice accumulator poisoned") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::live_state::live_state; + use crate::snapshot::HeatPhase; + use gridfpv_events::{AdapterId, GateIndex, HeatTransition, SourceTime}; + + fn chan(i: usize) -> CompetitorRef { + CompetitorRef(format!("node-{i}")) + } + + fn pass(node: usize, at: i64, seq: u64) -> Pass { + Pass { + adapter: AdapterId("sim".into()), + competitor: chan(node), + at: SourceTime::from_micros(at), + sequence: Some(seq), + gate: GateIndex::LAP, + signal: None, + heat: None, + } + } + + /// Build the real-log [`LiveRaceState`] base for an open-practice heat at a given lifecycle + /// point — the heat's real `HeatScheduled` over its channels plus the real transitions so far. + /// This is exactly what the fold sites compute before calling [`merge_into`]. + fn log_base( + heat: &HeatId, + channels: &[CompetitorRef], + transitions: &[HeatTransition], + ) -> LiveRaceState { + let mut events = vec![Event::HeatScheduled { + heat: heat.clone(), + lineup: channels.to_vec(), + class: None, + round: None, + frequencies: Vec::new(), + label: None, + }]; + for t in transitions { + events.push(Event::HeatStateChanged { + heat: heat.clone(), + transition: *t, + }); + } + live_state(&events) + } + + #[test] + fn idle_accumulator_has_no_live_state() { + let live = OpenPracticeLive::new(); + assert!(live.live_state().is_none()); + assert!(live.active_heat().is_none()); + assert!(!live.clear(), "clearing an idle accumulator is a no-op"); + assert!( + !live.record(pass(0, 0, 0)), + "a pass with no active heat is dropped" + ); + } + + #[test] + fn idle_accumulator_merge_is_the_log_base_unchanged() { + // With no active heat the merge returns the log base verbatim — the served state is purely + // the log fold. + let live = OpenPracticeLive::new(); + let heat = HeatId("q-1".into()); + let base = log_base(&heat, &[chan(0)], &[HeatTransition::Running]); + assert_eq!(live.merge_into(base.clone()), base); + } + + #[test] + fn merge_splices_per_channel_laps_onto_the_log_phase() { + // The accumulator holds the non-logged per-channel laps; the merge keeps the LOG's phase and + // lineup and only fills in the lap counts / last-lap + running order. + let live = OpenPracticeLive::new(); + let heat = HeatId("open-practice".into()); + let channels = vec![chan(0), chan(2)]; + live.begin(heat.clone(), channels.clone()); + + // node-0: holeshot + 2 laps (last lap 2.5s). node-2: holeshot + 1 lap (3.0s). + assert!(live.record(pass(0, 1_000_000, 0))); + assert!(live.record(pass(2, 1_500_000, 0))); + assert!(live.record(pass(0, 4_000_000, 1))); + assert!(live.record(pass(2, 4_500_000, 1))); + assert!(live.record(pass(0, 6_500_000, 2))); + + // The log base says the heat is Running (its real transition), with the two channels and no + // laps (the log carries no passes for an open-practice heat). + let base = log_base(&heat, &channels, &[HeatTransition::Running]); + assert_eq!(base.phase, HeatPhase::Running); + assert!(base.progress.iter().all(|p| p.laps_completed == 0)); + + let merged = live.merge_into(base); + // Phase + heat are the LOG's; laps are the overlay's. + assert_eq!(merged.current_heat, Some(heat)); + assert_eq!(merged.phase, HeatPhase::Running); + assert_eq!(merged.progress.len(), 2); + assert!(merged.progress.iter().all(|p| p.pilot.is_none())); + + let n0 = merged + .progress + .iter() + .find(|p| p.competitor == chan(0)) + .unwrap(); + assert_eq!(n0.laps_completed, 2); + assert_eq!(n0.last_lap_micros, Some(2_500_000)); + + let n2 = merged + .progress + .iter() + .find(|p| p.competitor == chan(2)) + .unwrap(); + assert_eq!(n2.laps_completed, 1); + + // Running order reflects the spliced laps (node-0 leads with 2 laps). + assert_eq!(merged.running_order, vec![chan(0), chan(2)]); + } + + #[test] + fn merge_follows_the_log_phase_through_unofficial_then_scheduled_on_restart() { + // The core desync fix: the served phase is the LOG's at every step. The accumulator holds + // laps throughout `Unofficial`; on `Restart` the bridge clears it, and the log base reads + // `Scheduled` (Restart fully resets, like Abort) — so the merge yields `Scheduled` with NO + // stale `Unofficial`. + let live = OpenPracticeLive::new(); + let heat = HeatId("open-practice".into()); + let channels = vec![chan(0)]; + live.begin(heat.clone(), channels.clone()); + live.record(pass(0, 1_000_000, 0)); // holeshot + live.record(pass(0, 4_000_000, 1)); // +1 lap (3.0s) + + // Running: log phase Running, laps spliced. + let running = live.merge_into(log_base(&heat, &channels, &[HeatTransition::Running])); + assert_eq!(running.phase, HeatPhase::Running); + assert_eq!(running.progress[0].laps_completed, 1); + + // Time limit fires → the LOG records `Finished` (Running → Unofficial). The accumulator is + // NOT cleared (laps held through Unofficial), so the merge keeps the laps but the phase is + // the log's `Unofficial`. + let unofficial = live.merge_into(log_base( + &heat, + &channels, + &[HeatTransition::Running, HeatTransition::Finished], + )); + assert_eq!( + unofficial.phase, + HeatPhase::Unofficial, + "the served phase follows the log to Unofficial" + ); + assert_eq!(unofficial.progress[0].laps_completed, 1); + + // RD hits Restart → the bridge clears the accumulator and the LOG records `Restarted` + // (heat back to `Scheduled`). The merge of the now-empty accumulator onto the `Scheduled` + // base is the bare log state: phase `Scheduled`, no stale `Unofficial`, laps cleared. + assert!(live.clear(), "restart clears the accumulator"); + let scheduled = live.merge_into(log_base( + &heat, + &channels, + &[ + HeatTransition::Running, + HeatTransition::Finished, + HeatTransition::Restarted, + ], + )); + assert_eq!( + scheduled.phase, + HeatPhase::Scheduled, + "after Restart the served phase is the log's Scheduled — never a stale Unofficial" + ); + assert_eq!( + scheduled.progress[0].laps_completed, 0, + "the per-channel laps are cleared after Restart" + ); + } + + #[test] + fn merge_does_not_splice_onto_a_different_current_heat() { + // A guard against a stale overlay bleeding laps onto another heat: if the log's current heat + // is not the accumulator's heat, the base is returned untouched. + let live = OpenPracticeLive::new(); + live.begin(HeatId("op".into()), vec![chan(0)]); + live.record(pass(0, 1_000_000, 0)); + live.record(pass(0, 4_000_000, 1)); + + let other = HeatId("q-7".into()); + let base = log_base(&other, &[chan(0)], &[HeatTransition::Running]); + let merged = live.merge_into(base.clone()); + assert_eq!(merged, base, "laps are not spliced onto a non-active heat"); + } + + #[test] + fn clear_drops_the_accumulator() { + let live = OpenPracticeLive::new(); + let heat = HeatId("open-practice".into()); + live.begin(heat.clone(), vec![chan(0)]); + live.record(pass(0, 0, 0)); + assert!(live.live_state().is_some()); + + assert!( + live.clear(), + "clearing an active accumulator reports a change" + ); + assert!(live.live_state().is_none()); + assert!(!live.is_active(&heat)); + assert!(live.active_heat().is_none()); + } + + #[test] + fn begin_replaces_a_prior_heat() { + let live = OpenPracticeLive::new(); + live.begin(HeatId("h1".into()), vec![chan(0)]); + live.record(pass(0, 0, 0)); + // A new heat takes over: the prior heat's laps are dropped. + live.begin(HeatId("h2".into()), vec![chan(1)]); + assert!(live.is_active(&HeatId("h2".into()))); + assert_eq!(live.active_heat(), Some(HeatId("h2".into()))); + let state = live.live_state().unwrap(); + assert_eq!(state.active_pilots, vec![chan(1)]); + } +} diff --git a/crates/server/src/pilots.rs b/crates/server/src/pilots.rs new file mode 100644 index 0000000..d767844 --- /dev/null +++ b/crates/server/src/pilots.rs @@ -0,0 +1,1216 @@ +//! Pilots as **application-level configuration** — the `PilotDirectory` and `Pilot` (issue #74). +//! +//! A pilot is a *racer in the Director's address book*: a callsign and a little optional metadata +//! (real name, VTX type, external service ids). The model parallels the timer model +//! ([`TimerRegistry`](crate::timers::TimerRegistry)): the Race Director maintains their pilots +//! **once** at the application level (a persisted directory) and each event simply builds a +//! **roster** of which directory pilots race it (see [`EventMeta::roster`](crate::events::EventMeta::roster)). +//! Type a pilot in once, and every new event just picks them. +//! +//! # Two pieces, mirroring timers +//! +//! - **App-level directory (this module).** The [`PilotDirectory`] holds every configured +//! [`Pilot`] behind a lock and **persists** them to `/pilots.json` +//! (restored on boot; in-memory only when no data dir is configured). Unlike timers there is +//! no built-in pilot — a fresh Director starts with an empty directory. +//! - **Per-event roster (`crate::events`).** Each [`EventMeta`](crate::events::EventMeta) carries +//! a `roster: Vec` of the directory pilots that event races; new events default to an +//! **empty** roster. +//! +//! Channels (which pilot flies which frequency in a given heat) are **not** modelled here — that +//! is a separate concern (#117). The directory is purely *who exists*; the roster is *who races +//! this event*. +//! +//! # Cloud-pull future hook (#74) +//! +//! The optional [`multigp_id`](Pilot::multigp_id) / [`velocidrone_id`](Pilot::velocidrone_id) +//! fields are deliberately carried (and persisted) now so a later **cloud-pull** — importing a +//! chapter's roster from MultiGP, or matching Velocidrone racers — has a stable place to record +//! the external identity it resolved a directory pilot from, without a schema change. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::scope::PilotId; + +/// The file name (under the data dir) the pilot directory is persisted to (issue #74). +pub const PILOTS_FILE: &str = "pilots.json"; + +/// The kind of **video transmitter** a pilot flies (issue #74). +/// +/// A small closed enum the directory records so the RD can group / display pilots by their video +/// system. Externally tagged (the default serde enum representation) so it maps to a TS string +/// union (`"Analog" | "HDZero" | "DJI" | "Walksnail" | "Other"`). A pilot carries a **set** of +/// these (see [`Pilot::vtx_types`]) since many pilots run more than one video system; the +/// [`Other`](VtxType::Other) variant is the catch-all for anything not enumerated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum VtxType { + /// Analog video (the classic 5.8GHz analog system). + Analog, + /// HDZero digital video. + HDZero, + /// DJI digital video (O3/O4/Air units). + DJI, + /// Walksnail Avatar digital video. + Walksnail, + /// Any other video system not enumerated above (catch-all). + Other, +} + +impl VtxType { + /// The canonical display order of the variants, used to keep a pilot's [`vtx_types`](Pilot::vtx_types) + /// set in a stable, deterministic order regardless of the order they were ticked. + const ORDER: [VtxType; 5] = [ + VtxType::Analog, + VtxType::HDZero, + VtxType::DJI, + VtxType::Walksnail, + VtxType::Other, + ]; +} + +/// Dedup a VTX list and return it in the canonical [`VtxType::ORDER`], so a pilot's +/// `vtx_types` is always a stable, duplicate-free set (issue #74). +fn normalize_vtx_types(vtx_types: &[VtxType]) -> Vec { + VtxType::ORDER + .iter() + .copied() + .filter(|kind| vtx_types.contains(kind)) + .collect() +} + +/// Deserialize a [`Pilot::vtx_types`] set, tolerating **both** the new and the old on-disk shape +/// (issue #74 follow-up): +/// +/// - the new shape — a JSON array of [`VtxType`] (`"vtx_types": ["Analog", "HDZero"]`) — loads as-is; +/// - the legacy shape — a single scalar `VtxType` (`"vtx_type": "Analog"`, reached through the field +/// `alias`) — migrates into a one-element set `[Analog]`. +/// +/// In every case the result is normalized (deduped, canonical order) so demo data persisted under +/// the old single-value `vtx_type` key survives the upgrade rather than being dropped on load. A +/// JSON `null` (legacy unset) maps to the empty set. +fn deserialize_vtx_types<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + /// Either the new array form or the old single-scalar form (or a legacy `null`). + #[derive(Deserialize)] + #[serde(untagged)] + enum VtxTypesCompat { + /// The new shape: a set of VTX types. + Many(Vec), + /// The legacy shape: a single optional VTX type (`Some(X)` → `[X]`, `None` → `[]`). + One(Option), + } + + let raw = match VtxTypesCompat::deserialize(deserializer)? { + VtxTypesCompat::Many(list) => list, + VtxTypesCompat::One(Some(one)) => vec![one], + VtxTypesCompat::One(None) => Vec::new(), + }; + Ok(normalize_vtx_types(&raw)) +} + +/// One pilot in the application-level directory (issue #74). +/// +/// The wire shape `GET /pilots` returns and the on-disk shape `pilots.json` persists: a stable +/// [`PilotId`] (auto-generated, never user-entered), a required `callsign`, and a little optional +/// metadata. The optional fields are all omitted from the wire when unset (`skip_serializing_if`) +/// so an entry with just a callsign serialises to a two-field object. Derives serde (its JSON *is* +/// both the wire and the persisted form) and `ts_rs::TS` so the frontend reads a generated `Pilot` +/// type. +/// +/// # Directory fields (a survey of RotorHazard / MultiGP / FPVScores, #74/#120) +/// +/// Beyond the core callsign + cloud-pull ids, the directory carries a small set of +/// **display/organizer** fields those systems converge on: a [`phonetic`](Pilot::phonetic) +/// pronunciation hint for voice callouts (RotorHazard), a [`team`](Pilot::team) / club name, a +/// [`color`](Pilot::color) for overlays/leaderboards, and a [`country`](Pilot::country) code. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct Pilot { + /// The stable handle a roster references and the API addresses (`PUT /pilots/{id}`). The same + /// [`PilotId`] the log's registration binding uses — directory and log never disagree on what a + /// pilot id is. + pub id: PilotId, + /// The pilot's **callsign** — the required display handle (their racing name). The directory's + /// one mandatory field; everything else is optional metadata. + pub callsign: String, + /// The pilot's real / full name, if recorded. Omitted from the wire when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub name: Option, + /// A **pronunciation hint** for the callsign, for voice callouts (RotorHazard carries this). + /// Free-form (e.g. `"AK-ro AYS"`). Omitted from the wire when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub phonetic: Option, + /// The pilot's **team / club** name, if recorded. Free-form. Omitted from the wire when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub team: Option, + /// A **hex color** `#RRGGBB` for overlays / leaderboards, if recorded. Stored as a plain + /// (normalized) string and lightly validated on create/update. Omitted from the wire when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub color: Option, + /// The pilot's **country** as an ISO 3166-1 alpha-2 code (e.g. `US`, `GB`), if recorded. The + /// **code only** — flags/names derive from it in the UI. Stored uppercase, lightly validated. + /// Omitted from the wire when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub country: Option, + /// The pilot's **video-transmitter system(s)** (see [`VtxType`]). An FPV pilot always flies + /// *some* video system, and many run more than one (e.g. Analog + HDZero), so this is a **set** + /// rather than a single optional value. Empty means *unspecified*. Kept deduplicated and in a + /// stable [`VtxType::ORDER`] on every create/update. Defaults empty (and, being a `Vec`, an + /// empty set still serializes to `[]`). + /// + /// **Persisted-format note:** the old on-disk shape was a single optional scalar + /// `vtx_type: VtxType`. A custom deserializer (see [`deserialize_vtx_types`]) accepts both the + /// new `vtx_types: [..]` array and migrates a legacy `vtx_type: X` into `[X]`, so existing + /// `pilots.json` rows load without data loss. + #[serde( + default, + deserialize_with = "deserialize_vtx_types", + alias = "vtx_type" + )] + pub vtx_types: Vec, + /// The pilot's **MultiGP** pilot id, if known — a forward hook for a later cloud-pull import + /// (#74). A free-form string. Omitted from the wire when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub multigp_id: Option, + /// The pilot's **Velocidrone** id, if known — a forward hook for matching a Velocidrone racer + /// (#74). A free-form string. Omitted from the wire when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub velocidrone_id: Option, +} + +/// The body of `POST /pilots` — the fields a caller supplies to create a pilot (issue #74). +/// +/// The `callsign` is required; everything else is optional. The **id is auto-generated** +/// server-side (a slug of the callsign + a short random suffix), never user-entered, mirroring +/// `POST /timers` / `POST /events`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct CreatePilotRequest { + /// The required callsign for the new pilot. + pub callsign: String, + /// Optional real name, stored on [`Pilot::name`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub name: Option, + /// Optional pronunciation hint, stored on [`Pilot::phonetic`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub phonetic: Option, + /// Optional team / club, stored on [`Pilot::team`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub team: Option, + /// Optional hex color `#RRGGBB`, stored on [`Pilot::color`] (validated). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub color: Option, + /// Optional ISO 3166-1 alpha-2 country code, stored on [`Pilot::country`] (validated). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub country: Option, + /// The pilot's video-transmitter system(s), stored (deduped, stable order) on + /// [`Pilot::vtx_types`]. Defaults empty (unspecified). + #[serde(default)] + pub vtx_types: Vec, + /// Optional MultiGP id, stored on [`Pilot::multigp_id`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub multigp_id: Option, + /// Optional Velocidrone id, stored on [`Pilot::velocidrone_id`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub velocidrone_id: Option, +} + +/// The body of `PUT /pilots/{id}` — the editable fields of a pilot (issue #74). +/// +/// Every field is optional so a partial edit is a one-field body; the id is fixed (it is in the +/// path). A present `callsign` replaces it (a blank one is ignored — the callsign is required and +/// never cleared). Each optional-metadata field is a three-state [`OptionalEdit`]: **absent** leaves +/// it unchanged ([`Keep`](OptionalEdit::Keep)), present **`null`** clears it +/// ([`Clear`](OptionalEdit::Clear)), and present **with a value** sets it ([`Set`](OptionalEdit::Set)). +/// The wire-level distinction between "field absent" and "field present and `null`" is what lets a +/// caller both leave-alone and clear — `#[serde(default)]` on each field maps an absent field to +/// `Keep` while a present `null`/value runs [`OptionalEdit`]'s deserializer. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct UpdatePilotRequest { + /// A new callsign, or `None`/blank to leave it unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub callsign: Option, + /// A new real name: present value → set, present `null` → clear, absent → leave unchanged. + #[serde(default, skip_serializing_if = "OptionalEdit::is_keep")] + #[ts(optional = nullable)] + pub name: OptionalEdit, + /// A new pronunciation hint (set / clear / leave-unchanged, like [`name`](Self::name)). + #[serde(default, skip_serializing_if = "OptionalEdit::is_keep")] + #[ts(optional = nullable)] + pub phonetic: OptionalEdit, + /// A new team / club (set / clear / leave-unchanged). + #[serde(default, skip_serializing_if = "OptionalEdit::is_keep")] + #[ts(optional = nullable)] + pub team: OptionalEdit, + /// A new hex color `#RRGGBB` (set / clear / leave-unchanged; a set value is validated). + #[serde(default, skip_serializing_if = "OptionalEdit::is_keep")] + #[ts(optional = nullable)] + pub color: OptionalEdit, + /// A new ISO 3166-1 alpha-2 country code (set / clear / leave-unchanged; a set value is + /// validated and normalized uppercase). + #[serde(default, skip_serializing_if = "OptionalEdit::is_keep")] + #[ts(optional = nullable)] + pub country: OptionalEdit, + /// A **full replacement** of the VTX set when present (absent leaves it unchanged; present `[]` + /// clears it) — a present array replaces the stored set wholesale (deduped, stable order), + /// rather than a per-value set/clear edit. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub vtx_types: Option>, + /// A new MultiGP id (set / clear / leave-unchanged). + #[serde(default, skip_serializing_if = "OptionalEdit::is_keep")] + #[ts(optional = nullable)] + pub multigp_id: OptionalEdit, + /// A new Velocidrone id (set / clear / leave-unchanged). + #[serde(default, skip_serializing_if = "OptionalEdit::is_keep")] + #[ts(optional = nullable)] + pub velocidrone_id: OptionalEdit, +} + +/// A **three-state** edit to an optional field: *leave unchanged*, *clear*, or *set* (issue #74). +/// +/// `PUT /pilots/{id}` needs to tell three wire cases apart for each optional field: +/// +/// - **field absent** ⇒ [`Keep`](OptionalEdit::Keep) — leave the stored value unchanged; +/// - **field present and `null`** ⇒ [`Clear`](OptionalEdit::Clear) — clear the stored value (`None`); +/// - **field present with a value** ⇒ [`Set`](OptionalEdit::Set) — set the stored value. +/// +/// The naive `Option>` does **not** work over serde: a wire `null` deserializes the +/// **same as an absent field** (both yield the outer `None`), so a field could never be *cleared* — +/// only set or left alone. This enum fixes that by pairing a custom [`Deserialize`] (which only ever +/// runs when the field is *present*, mapping `null` → `Clear` and a value → `Set`) with +/// `#[serde(default)]` on each request field (an *absent* field never calls the deserializer and so +/// defaults to [`Keep`](OptionalEdit::Keep)). On the TS side it renders as `T | null` (the field's +/// own `?:` carries the absent/present distinction). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum OptionalEdit { + /// The field was **absent** from the request — leave the stored value unchanged. + #[default] + Keep, + /// The field was present and `null` — **clear** the stored value (set it to `None`). + Clear, + /// The field was present with a value — **set** the stored value to it. + Set(T), +} + +impl Serialize for OptionalEdit +where + T: Serialize, +{ + /// Serializes back to the wire shape `T | null`: [`Set`](OptionalEdit::Set) is its value, + /// [`Clear`](OptionalEdit::Clear) is `null`. [`Keep`](OptionalEdit::Keep) also serializes to + /// `null` here, but in practice a `Keep` field is *skipped* entirely (see + /// [`OptionalEdit::is_keep`]) so an absent field stays absent on round-trip. + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + OptionalEdit::Set(value) => value.serialize(serializer), + OptionalEdit::Clear | OptionalEdit::Keep => serializer.serialize_none(), + } + } +} + +impl<'de, T> Deserialize<'de> for OptionalEdit +where + T: Deserialize<'de>, +{ + /// Only ever called when the field is **present** (an absent field defaults to + /// [`Keep`](OptionalEdit::Keep) via `#[serde(default)]`), so this maps the two present cases: + /// a JSON `null` → [`Clear`](OptionalEdit::Clear); any value → [`Set`](OptionalEdit::Set). + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Ok(match Option::::deserialize(deserializer)? { + Some(value) => OptionalEdit::Set(value), + None => OptionalEdit::Clear, + }) + } +} + +// A hand-written `TS` impl (rather than `#[derive(TS)]`) because the derive cannot render this +// three-variant enum as the flat `T | null` the wire actually is. It mirrors what the previous +// `#[serde(transparent)] struct OptionalEdit(Option)` derive emitted — the same +// `bindings/OptionalEdit.ts` (`type OptionalEdit = T | null;`) — so the TS shape is unchanged +// and gen-drift stays clean. The matching `export_bindings_optionaledit` test (see the tests module) +// stands in for the `#[ts(export)]`-generated export test the derive would otherwise produce. +impl TS for OptionalEdit { + type WithoutGenerics = OptionalEdit; + type OptionInnerType = Self; + + fn name(cfg: &ts_rs::Config) -> String { + format!("OptionalEdit<{}>", T::name(cfg)) + } + + fn inline(cfg: &ts_rs::Config) -> String { + format!("{} | null", T::inline(cfg)) + } + + fn inline_flattened(cfg: &ts_rs::Config) -> String { + Self::inline(cfg) + } + + fn decl(_cfg: &ts_rs::Config) -> String { + "type OptionalEdit = T | null;".to_owned() + } + + fn decl_concrete(cfg: &ts_rs::Config) -> String { + Self::decl(cfg) + } + + fn visit_dependencies(v: &mut impl ts_rs::TypeVisitor) + where + Self: 'static, + { + ::visit_dependencies(v); + } + + fn visit_generics(v: &mut impl ts_rs::TypeVisitor) + where + Self: 'static, + { + ::visit_generics(v); + v.visit::(); + } + + fn output_path() -> Option { + // Matches the `#[ts(export_to = "bindings/")]` the rest of the wire types use; combined with + // the workspace-root `TS_RS_EXPORT_DIR` (pinned by xtask), it lands at `bindings/OptionalEdit.ts`. + Some(std::path::PathBuf::from("bindings/OptionalEdit.ts")) + } +} + +impl OptionalEdit { + /// Whether this edit is [`Keep`](OptionalEdit::Keep) (the field was absent) — used as the + /// per-field `skip_serializing_if` so an unchanged field stays *absent* on the wire rather than + /// round-tripping to a `null` (which would mean *clear*). `pub(crate)` so sibling registries + /// (e.g. the class directory, #84) reuse the same three-state edit on their own request types. + pub(crate) fn is_keep(&self) -> bool { + matches!(self, OptionalEdit::Keep) + } +} + +/// The application-level directory of all configured pilots (issue #74). +/// +/// Maps each [`PilotId`] to its [`Pilot`]. The set is **persisted** to `/pilots.json` +/// (restored on boot) so the RD's address book survives a Director restart; with no data dir +/// configured it is in-memory only. Cloning shares the one directory (`Arc>`), so it is +/// reached through the axum router state ([`EventRegistry`](crate::events::EventRegistry)) exactly +/// like the [`TimerRegistry`](crate::timers::TimerRegistry). +#[derive(Clone)] +pub struct PilotDirectory { + inner: Arc>, +} + +/// The guarded interior: the pilot map and where `pilots.json` lives. +struct Directory { + /// `PilotId → Pilot`. A `BTreeMap` so listing is deterministic (id order). + pilots: BTreeMap, + /// Directory `pilots.json` is persisted under; `None` ⇒ in-memory only (no data dir). + data_dir: Option, +} + +impl PilotDirectory { + /// Build a directory, restoring `/pilots.json` when a data dir is given. + /// + /// Starts empty (unlike timers, there is no built-in pilot). When `data_dir` is `Some` and a + /// `pilots.json` already exists, the saved pilots are restored (an unreadable/corrupt file + /// degrades to an empty directory rather than failing to boot). When `data_dir` is `None` the + /// directory is in-memory only. + pub fn new(data_dir: Option) -> Result { + let mut pilots = BTreeMap::new(); + + if let Some(dir) = &data_dir { + std::fs::create_dir_all(dir).map_err(|e| { + PilotError::internal(format!("could not create data dir {}: {e}", dir.display())) + })?; + if let Some(restored) = read_persisted_pilots(dir) { + for pilot in restored { + pilots.insert(pilot.id.clone(), pilot); + } + } + } + + Ok(Self { + inner: Arc::new(RwLock::new(Directory { pilots, data_dir })), + }) + } + + /// Every pilot in id order — the `GET /pilots` body. + pub fn list(&self) -> Vec { + self.read().pilots.values().cloned().collect() + } + + /// Whether a pilot with `id` exists — the per-event roster validates each id through this. + pub fn exists(&self, id: &PilotId) -> bool { + self.read().pilots.contains_key(id) + } + + /// The [`Pilot`] for `id`, or `None`. + pub fn get(&self, id: &PilotId) -> Option { + self.read().pilots.get(id).cloned() + } + + /// Create a pilot from a [`CreatePilotRequest`], returning it (issue #74). + /// + /// The **id is auto-generated** — a slug of the `callsign` + a short random suffix — so it is + /// unique and never user-entered. The optional metadata is stored verbatim (trimmed, with a + /// blank treated as unset). The directory is **persisted** on success. + pub fn create(&self, request: &CreatePilotRequest) -> Result { + let callsign = request.callsign.trim(); + if callsign.is_empty() { + return Err(PilotError::invalid("a pilot callsign is required")); + } + let color = normalize_color(&request.color)?; + let country = normalize_country(&request.country)?; + let mut dir = self.write(); + let id = loop { + let candidate = PilotId(format!("{}-{}", slugify(callsign), short_suffix())); + if !dir.pilots.contains_key(&candidate) { + break candidate; + } + }; + let pilot = Pilot { + id: id.clone(), + callsign: callsign.to_string(), + name: normalize_optional(&request.name), + phonetic: normalize_optional(&request.phonetic), + team: normalize_optional(&request.team), + color, + country, + vtx_types: normalize_vtx_types(&request.vtx_types), + multigp_id: normalize_optional(&request.multigp_id), + velocidrone_id: normalize_optional(&request.velocidrone_id), + }; + dir.pilots.insert(id, pilot.clone()); + dir.persist()?; + Ok(pilot) + } + + /// Edit a pilot's fields (issue #74), returning the updated [`Pilot`]. + /// + /// A present `callsign` replaces it (a blank one is ignored — the callsign is required). Each + /// optional metadata field is a three-way edit: absent → unchanged; present `Some(value)` → + /// set (trimmed; a blank string clears it); present `null` → cleared. An unknown id is a + /// [`PilotError`]. The directory is **persisted** on success. + pub fn update(&self, id: &PilotId, request: &UpdatePilotRequest) -> Result { + // Validate the set-edits up front (before taking the lock / mutating) so a bad value is a + // clean rejection that leaves the stored pilot untouched. + let color_edit = validate_color_edit(&request.color)?; + let country_edit = validate_country_edit(&request.country)?; + + let mut dir = self.write(); + let pilot = dir + .pilots + .get_mut(id) + .ok_or_else(|| PilotError::not_found(format!("no pilot with id {:?}", id.0)))?; + if let Some(callsign) = &request.callsign { + let trimmed = callsign.trim(); + if !trimmed.is_empty() { + pilot.callsign = trimmed.to_string(); + } + } + apply_string_edit(&mut pilot.name, &request.name); + apply_string_edit(&mut pilot.phonetic, &request.phonetic); + apply_string_edit(&mut pilot.team, &request.team); + if let Some(value) = color_edit { + pilot.color = value; + } + if let Some(value) = country_edit { + pilot.country = value; + } + // VTX is a full-replacement set: a present array replaces the stored set (normalized); + // `Some([])` clears it; absent (`None`) leaves it unchanged. + if let Some(vtx_types) = &request.vtx_types { + pilot.vtx_types = normalize_vtx_types(vtx_types); + } + apply_string_edit(&mut pilot.multigp_id, &request.multigp_id); + apply_string_edit(&mut pilot.velocidrone_id, &request.velocidrone_id); + let updated = pilot.clone(); + dir.persist()?; + Ok(updated) + } + + /// Delete a pilot (issue #74). An unknown id is a [`PilotError`]. The directory is + /// **persisted** on success. (Removing a pilot does not touch any event roster that still + /// names them; a stale roster id is harmless — see the events module.) + pub fn delete(&self, id: &PilotId) -> Result<(), PilotError> { + let mut dir = self.write(); + if dir.pilots.remove(id).is_none() { + return Err(PilotError::not_found(format!( + "no pilot with id {:?}", + id.0 + ))); + } + dir.persist()?; + Ok(()) + } + + fn read(&self) -> std::sync::RwLockReadGuard<'_, Directory> { + self.inner.read().expect("pilot directory lock poisoned") + } + + fn write(&self) -> std::sync::RwLockWriteGuard<'_, Directory> { + self.inner.write().expect("pilot directory lock poisoned") + } +} + +impl Directory { + /// Persist the pilot set to `/pilots.json` (issue #74), a no-op with no data dir. + fn persist(&self) -> Result<(), PilotError> { + let Some(dir) = &self.data_dir else { + return Ok(()); + }; + let pilots: Vec<&Pilot> = self.pilots.values().collect(); + let json = serde_json::to_string_pretty(&pilots) + .map_err(|e| PilotError::internal(format!("could not serialize pilots: {e}")))?; + std::fs::write(pilots_path(dir), json) + .map_err(|e| PilotError::internal(format!("could not persist pilots: {e}"))) + } +} + +/// Apply an optional string edit to a stored field: [`Keep`](OptionalEdit::Keep) → unchanged; +/// [`Clear`](OptionalEdit::Clear) → cleared; [`Set`](OptionalEdit::Set) → set (trimmed, with a blank +/// value treated as a clear). +fn apply_string_edit(field: &mut Option, edit: &OptionalEdit) { + match edit { + OptionalEdit::Keep => {} + OptionalEdit::Clear => *field = None, + OptionalEdit::Set(value) => { + *field = Some(value.trim().to_string()).filter(|s| !s.is_empty()); + } + } +} + +/// The file the pilot set is persisted to under `dir`: `/pilots.json`. +fn pilots_path(dir: &Path) -> PathBuf { + dir.join(PILOTS_FILE) +} + +/// Read the persisted pilots from `/pilots.json`, or `None` if absent/unreadable/corrupt. +/// A bad file degrades to "no persisted pilots" so the Director still boots. +fn read_persisted_pilots(dir: &Path) -> Option> { + let raw = std::fs::read_to_string(pilots_path(dir)).ok()?; + serde_json::from_str(&raw).ok() +} + +/// An error mutating the pilot directory (a persistence failure, an unknown id, a missing callsign, +/// an invalid field value). Carries a [`PilotErrorKind`] so the HTTP layer can map a *validation* +/// failure to `400` and an *unknown id* to `404`. +#[derive(Debug, Clone)] +pub struct PilotError { + /// What kind of failure this is (drives the HTTP status the handler picks). + pub kind: PilotErrorKind, + /// A human-readable message. + pub message: String, +} + +/// The class of a [`PilotError`], so a handler can pick the right status (issue #74). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PilotErrorKind { + /// A bad request value (missing callsign, invalid color/country, …) → 400. + Invalid, + /// The addressed pilot id does not exist → 404. + NotFound, + /// A server-side persistence failure → 500. + Internal, +} + +impl PilotError { + /// A validation / bad-request error (HTTP 400). + fn invalid(message: impl Into) -> Self { + Self { + kind: PilotErrorKind::Invalid, + message: message.into(), + } + } + + /// An unknown-id error (HTTP 404). + fn not_found(message: impl Into) -> Self { + Self { + kind: PilotErrorKind::NotFound, + message: message.into(), + } + } + + /// An internal / persistence error (HTTP 500). + fn internal(message: impl Into) -> Self { + Self { + kind: PilotErrorKind::Internal, + message: message.into(), + } + } +} + +impl std::fmt::Display for PilotError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "pilot directory error: {}", self.message) + } +} + +impl std::error::Error for PilotError {} + +/// Trim an optional field, treating a blank/whitespace-only value as **unset** (`None`). +fn normalize_optional(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +/// Validate + normalize a **hex color** `#RRGGBB` (issue #74). +/// +/// A blank/absent value is unset (`None`); otherwise the value must be a `#` followed by exactly six +/// ASCII hex digits (case-insensitive), normalized to an uppercase `#RRGGBB`. Anything else is a +/// validation [`PilotError`]. +fn normalize_color(value: &Option) -> Result, PilotError> { + let Some(raw) = value.as_deref().map(str::trim).filter(|s| !s.is_empty()) else { + return Ok(None); + }; + let hex = raw.strip_prefix('#').unwrap_or(""); + if hex.len() == 6 && hex.bytes().all(|b| b.is_ascii_hexdigit()) { + Ok(Some(format!("#{}", hex.to_ascii_uppercase()))) + } else { + Err(PilotError::invalid(format!( + "invalid color {raw:?}: expected a hex color like #RRGGBB" + ))) + } +} + +/// Validate + normalize an **ISO 3166-1 alpha-2** country code (issue #74). +/// +/// A blank/absent value is unset (`None`); otherwise the value must be exactly two ASCII letters, +/// normalized to uppercase (e.g. `gb` → `GB`). We deliberately do **not** check the code against a +/// full country table — that lives in the UI. Anything else is a validation [`PilotError`]. +fn normalize_country(value: &Option) -> Result, PilotError> { + let Some(raw) = value.as_deref().map(str::trim).filter(|s| !s.is_empty()) else { + return Ok(None); + }; + if raw.len() == 2 && raw.bytes().all(|b| b.is_ascii_alphabetic()) { + Ok(Some(raw.to_ascii_uppercase())) + } else { + Err(PilotError::invalid(format!( + "invalid country {raw:?}: expected a two-letter ISO 3166-1 alpha-2 code" + ))) + } +} + +/// Validate a *set-or-clear* edit of a validated optional field (color/country), returning the +/// outer edit (issue #74): [`Keep`](OptionalEdit::Keep) ⇒ `None` (leave unchanged); +/// [`Clear`](OptionalEdit::Clear) ⇒ `Some(None)` (clear); [`Set`](OptionalEdit::Set) ⇒ +/// `Some(normalized)` (set to the validated value). A set value that fails `normalize` is a +/// validation [`PilotError`]. +fn validate_edit_with( + edit: &OptionalEdit, + normalize: impl Fn(&Option) -> Result, PilotError>, +) -> Result>, PilotError> { + match edit { + OptionalEdit::Keep => Ok(None), + OptionalEdit::Clear => Ok(Some(None)), + OptionalEdit::Set(value) => Ok(Some(normalize(&Some(value.clone()))?)), + } +} + +/// Validate a color set-or-clear edit (see [`validate_edit_with`] / [`normalize_color`]). +fn validate_color_edit(edit: &OptionalEdit) -> Result>, PilotError> { + validate_edit_with(edit, normalize_color) +} + +/// Validate a country set-or-clear edit (see [`validate_edit_with`] / [`normalize_country`]). +fn validate_country_edit( + edit: &OptionalEdit, +) -> Result>, PilotError> { + validate_edit_with(edit, normalize_country) +} + +/// Slugify a callsign into an id-friendly stem (the same rule as the event/timer registries): +/// lowercase ASCII alphanumerics kept, every other run collapsed to a single `-`, trimmed of +/// dashes; an empty/symbol-only callsign yields `pilot`. +fn slugify(name: &str) -> String { + let mut slug = String::new(); + let mut prev_dash = false; + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + prev_dash = false; + } else if !prev_dash { + slug.push('-'); + prev_dash = true; + } + } + let trimmed = slug.trim_matches('-'); + if trimmed.is_empty() { + "pilot".to_string() + } else { + trimmed.to_string() + } +} + +/// A short random lowercase-alphanumeric suffix making an auto-generated id unique (same source as +/// the event/timer registries — the OS CSPRNG). +fn short_suffix() -> String { + const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + let mut bytes = [0u8; 6]; + getrandom::fill(&mut bytes).expect("OS CSPRNG available"); + bytes + .iter() + .map(|b| ALPHABET[(*b as usize) % ALPHABET.len()] as char) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn req(callsign: &str) -> CreatePilotRequest { + CreatePilotRequest { + callsign: callsign.to_string(), + ..Default::default() + } + } + + #[test] + fn a_fresh_directory_is_empty() { + let dir = PilotDirectory::new(None).unwrap(); + assert!(dir.list().is_empty()); + } + + #[test] + fn create_auto_generates_a_unique_slug_id() { + let dir = PilotDirectory::new(None).unwrap(); + let a = dir.create(&req("Acro Ace!")).unwrap(); + let b = dir.create(&req("Acro Ace!")).unwrap(); + assert!(a.id.0.starts_with("acro-ace-")); + assert_ne!(a.id, b.id); + assert_eq!(a.callsign, "Acro Ace!"); + // Both listed. + let ids: Vec<_> = dir.list().into_iter().map(|p| p.id).collect(); + assert!(ids.contains(&a.id) && ids.contains(&b.id)); + } + + #[test] + fn create_requires_a_callsign() { + let dir = PilotDirectory::new(None).unwrap(); + assert!(dir.create(&req(" ")).is_err()); + assert!(dir.list().is_empty()); + } + + #[test] + fn create_stores_optional_metadata_including_cloud_pull_ids() { + let dir = PilotDirectory::new(None).unwrap(); + let pilot = dir + .create(&CreatePilotRequest { + callsign: "Zoom".to_string(), + name: Some("Zoe Oom".to_string()), + // Multiple VTX types, deliberately out of canonical order + a duplicate, to prove + // create dedups and reorders. + vtx_types: vec![VtxType::HDZero, VtxType::Analog, VtxType::HDZero], + multigp_id: Some("mgp-123".to_string()), + velocidrone_id: Some(" ".to_string()), // blank → unset + ..Default::default() + }) + .unwrap(); + assert_eq!(pilot.name.as_deref(), Some("Zoe Oom")); + // Deduped + canonical order (Analog before HDZero). + assert_eq!(pilot.vtx_types, vec![VtxType::Analog, VtxType::HDZero]); + assert_eq!(pilot.multigp_id.as_deref(), Some("mgp-123")); + assert_eq!(pilot.velocidrone_id, None); + } + + #[test] + fn update_edits_callsign_and_optional_fields_with_clear_semantics() { + let dir = PilotDirectory::new(None).unwrap(); + let created = dir + .create(&CreatePilotRequest { + callsign: "Old".to_string(), + name: Some("Real Name".to_string()), + vtx_types: vec![VtxType::Analog], + ..Default::default() + }) + .unwrap(); + + // Set callsign + multigp_id, clear name, leave vtx_types unchanged (absent). + let updated = dir + .update( + &created.id, + &UpdatePilotRequest { + callsign: Some("New".to_string()), + name: OptionalEdit::Clear, // explicit clear + multigp_id: OptionalEdit::Set("mgp-9".to_string()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(updated.callsign, "New"); + assert_eq!(updated.name, None); + assert_eq!(updated.vtx_types, vec![VtxType::Analog]); // absent → unchanged + assert_eq!(updated.multigp_id.as_deref(), Some("mgp-9")); + + // A blank callsign is ignored (required field never cleared). + let again = dir + .update( + &created.id, + &UpdatePilotRequest { + callsign: Some(" ".to_string()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(again.callsign, "New"); + } + + #[test] + fn optional_edit_deserializes_the_three_wire_cases() { + // A present value → Set; a present `null` → Clear; an absent field → Keep (the default). + // This is the core of the fix: before, a wire `null` collapsed to the *absent* case and + // could never clear a field. + let set: UpdatePilotRequest = serde_json::from_str(r#"{"team":"X"}"#).unwrap(); + assert_eq!(set.team, OptionalEdit::Set("X".to_string())); + + let clear: UpdatePilotRequest = serde_json::from_str(r#"{"team":null}"#).unwrap(); + assert_eq!(clear.team, OptionalEdit::Clear); + + let absent: UpdatePilotRequest = serde_json::from_str("{}").unwrap(); + assert_eq!(absent.team, OptionalEdit::Keep); + } + + /// Round-trip a JSON body through `update` (persisted to `pilots.json`) to prove the three wire + /// cases — set, clear-via-`null`, leave-via-absent — apply to the stored pilot. The headline: + /// **`color` and `country` can now be cleared** (their `#hex` / 2-letter validation rejects the + /// empty string, so a wire `null` is the only path to clear them). + #[test] + fn wire_null_clears_color_and_country_through_persistence() { + let data_dir = + std::env::temp_dir().join(format!("gridfpv-pilots-clear-{}", short_suffix())); + { + let dir = PilotDirectory::new(Some(data_dir.clone())).unwrap(); + let created = dir + .create(&CreatePilotRequest { + callsign: "Clearable".to_string(), + team: Some("Team Zoom".to_string()), + color: Some("#abcdef".to_string()), + country: Some("de".to_string()), + ..Default::default() + }) + .unwrap(); + + // `{"color":"#112233","country":"US"}` → both *set* (normalized uppercase); team absent + // → unchanged. + let set_body: UpdatePilotRequest = + serde_json::from_str(r##"{"color":"#112233","country":"us"}"##).unwrap(); + let set = dir.update(&created.id, &set_body).unwrap(); + assert_eq!(set.color.as_deref(), Some("#112233")); + assert_eq!(set.country.as_deref(), Some("US")); + assert_eq!(set.team.as_deref(), Some("Team Zoom")); // absent → unchanged + + // `{"color":null,"country":null}` → both *cleared* (the case the old code could not + // express for validated fields). team still absent → still unchanged. + let clear_body: UpdatePilotRequest = + serde_json::from_str(r#"{"color":null,"country":null}"#).unwrap(); + let cleared = dir.update(&created.id, &clear_body).unwrap(); + assert_eq!(cleared.color, None, "wire null must clear color"); + assert_eq!(cleared.country, None, "wire null must clear country"); + assert_eq!(cleared.team.as_deref(), Some("Team Zoom")); + + // The clear survives a restart (it was persisted to pilots.json, not just held in mem). + let reopened = PilotDirectory::new(Some(data_dir.clone())).unwrap(); + let got = reopened.get(&created.id).unwrap(); + assert_eq!(got.color, None); + assert_eq!(got.country, None); + assert_eq!(got.team.as_deref(), Some("Team Zoom")); + } + std::fs::remove_dir_all(&data_dir).ok(); + } + + #[test] + fn update_and_delete_reject_unknown_ids() { + let dir = PilotDirectory::new(None).unwrap(); + let unknown = PilotId("nope".into()); + assert!( + dir.update( + &unknown, + &UpdatePilotRequest { + callsign: Some("X".into()), + ..Default::default() + }, + ) + .is_err() + ); + assert!(dir.delete(&unknown).is_err()); + } + + #[test] + fn delete_removes_a_created_pilot() { + let dir = PilotDirectory::new(None).unwrap(); + let created = dir.create(&req("Temp")).unwrap(); + assert!(dir.exists(&created.id)); + dir.delete(&created.id).unwrap(); + assert!(!dir.exists(&created.id)); + assert!(dir.delete(&created.id).is_err()); + } + + #[test] + fn pilots_persist_across_a_restart_with_a_data_dir() { + let data_dir = std::env::temp_dir().join(format!("gridfpv-pilots-test-{}", short_suffix())); + { + let dir = PilotDirectory::new(Some(data_dir.clone())).unwrap(); + let created = dir + .create(&CreatePilotRequest { + callsign: "Persisted".to_string(), + name: Some("Per Sisted".to_string()), + phonetic: Some("PER sis-ted".to_string()), + team: Some("Team Zoom".to_string()), + color: Some("#ff0000".to_string()), + country: Some("gb".to_string()), + vtx_types: vec![VtxType::DJI, VtxType::Other], + multigp_id: Some("mgp-7".to_string()), + velocidrone_id: None, + }) + .unwrap(); + + let reopened = PilotDirectory::new(Some(data_dir.clone())).unwrap(); + let got = reopened.get(&created.id).unwrap(); + assert_eq!(got.callsign, "Persisted"); + assert_eq!(got.name.as_deref(), Some("Per Sisted")); + assert_eq!(got.phonetic.as_deref(), Some("PER sis-ted")); + assert_eq!(got.team.as_deref(), Some("Team Zoom")); + assert_eq!(got.color.as_deref(), Some("#FF0000")); // normalized uppercase + assert_eq!(got.country.as_deref(), Some("GB")); // normalized uppercase + assert_eq!(got.vtx_types, vec![VtxType::DJI, VtxType::Other]); + assert_eq!(got.multigp_id.as_deref(), Some("mgp-7")); + assert_eq!(got.velocidrone_id, None); + } + std::fs::remove_dir_all(&data_dir).ok(); + } + + #[test] + fn color_validation_accepts_hex_and_rejects_garbage() { + let dir = PilotDirectory::new(None).unwrap(); + // Accepts #RRGGBB (any case), normalizes to uppercase. + let ok = dir + .create(&CreatePilotRequest { + callsign: "Hexy".to_string(), + color: Some("#AbCdEf".to_string()), + ..Default::default() + }) + .unwrap(); + assert_eq!(ok.color.as_deref(), Some("#ABCDEF")); + + // Rejects non-hex / wrong length / missing #. + for bad in ["red", "#12345", "#1234567", "1188ff", "#zzzzzz"] { + let err = dir + .create(&CreatePilotRequest { + callsign: "Bad".to_string(), + color: Some(bad.to_string()), + ..Default::default() + }) + .unwrap_err(); + assert_eq!(err.kind, PilotErrorKind::Invalid, "should reject {bad:?}"); + } + + // An update with a bad color is rejected and leaves the stored value untouched. + let bad_update = dir + .update( + &ok.id, + &UpdatePilotRequest { + color: OptionalEdit::Set("nope".to_string()), + ..Default::default() + }, + ) + .unwrap_err(); + assert_eq!(bad_update.kind, PilotErrorKind::Invalid); + assert_eq!(dir.get(&ok.id).unwrap().color.as_deref(), Some("#ABCDEF")); + + // Clearing the color works. + let cleared = dir + .update( + &ok.id, + &UpdatePilotRequest { + color: OptionalEdit::Clear, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(cleared.color, None); + } + + #[test] + fn country_validation_accepts_two_letters_and_rejects_others() { + let dir = PilotDirectory::new(None).unwrap(); + let ok = dir + .create(&CreatePilotRequest { + callsign: "Yank".to_string(), + country: Some("us".to_string()), + ..Default::default() + }) + .unwrap(); + assert_eq!(ok.country.as_deref(), Some("US")); + + for bad in ["USA", "U", "12", "U1", "g b"] { + let err = dir + .create(&CreatePilotRequest { + callsign: "Bad".to_string(), + country: Some(bad.to_string()), + ..Default::default() + }) + .unwrap_err(); + assert_eq!(err.kind, PilotErrorKind::Invalid, "should reject {bad:?}"); + } + + // Update to a valid code (normalized) and then clear. + let updated = dir + .update( + &ok.id, + &UpdatePilotRequest { + country: OptionalEdit::Set("De".to_string()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(updated.country.as_deref(), Some("DE")); + } + + #[test] + fn vtx_types_replace_dedup_and_clear_via_empty_array() { + let dir = PilotDirectory::new(None).unwrap(); + let created = dir + .create(&CreatePilotRequest { + callsign: "Multi".to_string(), + vtx_types: vec![VtxType::Analog, VtxType::HDZero], + ..Default::default() + }) + .unwrap(); + assert_eq!(created.vtx_types, vec![VtxType::Analog, VtxType::HDZero]); + + // A present array fully replaces the set (and is deduped + reordered to canonical order). + let replaced = dir + .update( + &created.id, + &UpdatePilotRequest { + vtx_types: Some(vec![VtxType::Other, VtxType::DJI, VtxType::Other]), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(replaced.vtx_types, vec![VtxType::DJI, VtxType::Other]); + + // An absent vtx_types leaves the set unchanged. + let unchanged = dir + .update( + &created.id, + &UpdatePilotRequest { + name: OptionalEdit::Set("Nom".to_string()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(unchanged.vtx_types, vec![VtxType::DJI, VtxType::Other]); + + // A present empty array clears the set (there is no "None" concept — empty = unspecified). + let cleared = dir + .update( + &created.id, + &UpdatePilotRequest { + vtx_types: Some(vec![]), + ..Default::default() + }, + ) + .unwrap(); + assert!(cleared.vtx_types.is_empty()); + } + + #[test] + fn loads_legacy_scalar_vtx_type_as_a_one_element_set() { + // An existing pilots.json row written before this change carries the old scalar + // `vtx_type: "HDZero"` (and no `vtx_types`). It must migrate to `vtx_types: ["HDZero"]` + // rather than crashing the boot or losing the value. + let data_dir = + std::env::temp_dir().join(format!("gridfpv-pilots-legacy-{}", short_suffix())); + std::fs::create_dir_all(&data_dir).unwrap(); + std::fs::write( + pilots_path(&data_dir), + br#"[{"id":"legacy-1","callsign":"Legacy","vtx_type":"HDZero","attributes":{}}]"#, + ) + .unwrap(); + + let dir = PilotDirectory::new(Some(data_dir.clone())).unwrap(); + let got = dir.get(&PilotId("legacy-1".into())).unwrap(); + assert_eq!(got.callsign, "Legacy"); + assert_eq!( + got.vtx_types, + vec![VtxType::HDZero], + "legacy scalar migrates" + ); + + // A legacy row that never set a VTX (no key at all) loads as the empty set. + std::fs::write( + pilots_path(&data_dir), + br#"[{"id":"legacy-2","callsign":"NoVtx","attributes":{}}]"#, + ) + .unwrap(); + let reopened = PilotDirectory::new(Some(data_dir.clone())).unwrap(); + let none = reopened.get(&PilotId("legacy-2".into())).unwrap(); + assert!(none.vtx_types.is_empty()); + + std::fs::remove_dir_all(&data_dir).ok(); + } + + #[test] + fn loads_a_legacy_row_that_still_carries_a_dropped_attributes_field() { + // Pilots created before the custom-attributes bag was removed still have an `"attributes"` + // key on disk. The `Pilot` type no longer has the field; the row must still load (the stale + // key is simply ignored — `Pilot` must NOT use `deny_unknown_fields`), with no migration. + let data_dir = + std::env::temp_dir().join(format!("gridfpv-pilots-attrs-{}", short_suffix())); + std::fs::create_dir_all(&data_dir).unwrap(); + std::fs::write( + pilots_path(&data_dir), + br#"[{"id":"legacy-attr","callsign":"OldAttr","vtx_types":["Analog"],"attributes":{"bib":"7","insurance":"AMA-123"}}]"#, + ) + .unwrap(); + + let dir = PilotDirectory::new(Some(data_dir.clone())).unwrap(); + let got = dir.get(&PilotId("legacy-attr".into())).unwrap(); + assert_eq!(got.callsign, "OldAttr"); + assert_eq!(got.vtx_types, vec![VtxType::Analog]); + + std::fs::remove_dir_all(&data_dir).ok(); + } + + #[test] + fn a_corrupt_pilots_file_degrades_to_an_empty_directory() { + let data_dir = std::env::temp_dir().join(format!("gridfpv-pilots-bad-{}", short_suffix())); + std::fs::create_dir_all(&data_dir).unwrap(); + std::fs::write(pilots_path(&data_dir), b"not json at all").unwrap(); + let dir = PilotDirectory::new(Some(data_dir.clone())).unwrap(); + assert!(dir.list().is_empty()); + std::fs::remove_dir_all(&data_dir).ok(); + } +} diff --git a/crates/server/src/plugin_bundle.rs b/crates/server/src/plugin_bundle.rs new file mode 100644 index 0000000..d01f3e2 --- /dev/null +++ b/crates/server/src/plugin_bundle.rs @@ -0,0 +1,168 @@ +//! The downloadable **GridFPV RotorHazard plugin bundle** (RH plugin design D16, Slice 1). +//! +//! The Director's required-with-guided-install UX (§5) offers a one-step install: download this +//! bundle, drop the `gridfpv/` folder into RotorHazard's `plugins/` dir, and restart RH. The +//! plugin source is **embedded at compile time** ([`include_str!`]) so the served bundle is always +//! the exact build this Director speaks to (its `DIRECTOR_PROTOCOL_VERSION`) — never a drifting +//! out-of-band copy. +//! +//! The archive is a **STORE-only ZIP** (no compression) built by hand: the payload is three tiny +//! text files, so a dependency-free writer beats pulling in a zip crate (the repo keeps its +//! dependency surface small — cf. `xtask`'s hand-rolled HTTP). The entries are nested under a +//! top-level `gridfpv/` folder, so unzipping yields exactly the folder to drop into `plugins/`. + +/// One embedded plugin file: its path inside the zip and its bytes. +struct BundleFile { + /// Path within the archive (under the top-level `gridfpv/` folder). + name: &'static str, + /// File contents, embedded from the in-repo plugin at build time. + bytes: &'static [u8], +} + +/// The plugin files, embedded from `plugins/gridfpv/` at compile time. Keep this in step with the +/// plugin folder — a new file there must be added here to ship in the bundle. +const FILES: &[BundleFile] = &[ + BundleFile { + name: "gridfpv/__init__.py", + bytes: include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../plugins/gridfpv/__init__.py" + )), + }, + BundleFile { + name: "gridfpv/manifest.json", + bytes: include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../plugins/gridfpv/manifest.json" + )), + }, + BundleFile { + name: "gridfpv/README.md", + bytes: include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../plugins/gridfpv/README.md" + )), + }, +]; + +/// The download filename offered to the browser. +pub const BUNDLE_FILENAME: &str = "gridfpv-plugin.zip"; + +/// Build the GridFPV plugin bundle as a STORE-only ZIP byte vector. Deterministic (no timestamps; +/// DOS date/time are zeroed), so the same plugin source always yields the same bytes. +pub fn plugin_zip() -> Vec { + let mut out = Vec::new(); + // (offset of each local header, crc32, size) for the central directory pass. + let mut entries: Vec<(u32, u32, u32, &'static str)> = Vec::with_capacity(FILES.len()); + + for file in FILES { + let offset = out.len() as u32; + let crc = crc32(file.bytes); + let size = file.bytes.len() as u32; + let name = file.name.as_bytes(); + + // Local file header. + out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); // signature + out.extend_from_slice(&20u16.to_le_bytes()); // version needed + out.extend_from_slice(&0u16.to_le_bytes()); // flags + out.extend_from_slice(&0u16.to_le_bytes()); // compression: 0 = store + out.extend_from_slice(&0u16.to_le_bytes()); // mod time (zeroed) + out.extend_from_slice(&0u16.to_le_bytes()); // mod date (zeroed) + out.extend_from_slice(&crc.to_le_bytes()); + out.extend_from_slice(&size.to_le_bytes()); // compressed size == uncompressed (store) + out.extend_from_slice(&size.to_le_bytes()); // uncompressed size + out.extend_from_slice(&(name.len() as u16).to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); // extra length + out.extend_from_slice(name); + out.extend_from_slice(file.bytes); + + entries.push((offset, crc, size, file.name)); + } + + // Central directory. + let cd_offset = out.len() as u32; + for (offset, crc, size, name) in &entries { + let name = name.as_bytes(); + out.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); // signature + out.extend_from_slice(&20u16.to_le_bytes()); // version made by + out.extend_from_slice(&20u16.to_le_bytes()); // version needed + out.extend_from_slice(&0u16.to_le_bytes()); // flags + out.extend_from_slice(&0u16.to_le_bytes()); // compression: store + out.extend_from_slice(&0u16.to_le_bytes()); // mod time + out.extend_from_slice(&0u16.to_le_bytes()); // mod date + out.extend_from_slice(&crc.to_le_bytes()); + out.extend_from_slice(&size.to_le_bytes()); // compressed size + out.extend_from_slice(&size.to_le_bytes()); // uncompressed size + out.extend_from_slice(&(name.len() as u16).to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); // extra length + out.extend_from_slice(&0u16.to_le_bytes()); // comment length + out.extend_from_slice(&0u16.to_le_bytes()); // disk number start + out.extend_from_slice(&0u16.to_le_bytes()); // internal attrs + out.extend_from_slice(&0u32.to_le_bytes()); // external attrs + out.extend_from_slice(&offset.to_le_bytes()); // local header offset + out.extend_from_slice(name); + } + let cd_size = out.len() as u32 - cd_offset; + + // End of central directory. + let count = entries.len() as u16; + out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); // signature + out.extend_from_slice(&0u16.to_le_bytes()); // disk number + out.extend_from_slice(&0u16.to_le_bytes()); // disk with CD + out.extend_from_slice(&count.to_le_bytes()); // entries on this disk + out.extend_from_slice(&count.to_le_bytes()); // total entries + out.extend_from_slice(&cd_size.to_le_bytes()); + out.extend_from_slice(&cd_offset.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); // comment length + + out +} + +/// CRC-32 (IEEE 802.3 polynomial `0xEDB88320`, the variant ZIP uses), computed bitwise. The +/// payload is a few KB of text, so a table-free loop is plenty and keeps this self-contained. +fn crc32(bytes: &[u8]) -> u32 { + let mut crc: u32 = 0xFFFF_FFFF; + for &byte in bytes { + crc ^= byte as u32; + for _ in 0..8 { + let mask = (crc & 1).wrapping_neg(); + crc = (crc >> 1) ^ (0xEDB8_8320 & mask); + } + } + !crc +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crc32_matches_known_vector() { + // The canonical CRC-32 of "123456789" is 0xCBF43926. + assert_eq!(crc32(b"123456789"), 0xCBF4_3926); + } + + #[test] + fn zip_has_signatures_and_all_files() { + let zip = plugin_zip(); + // Starts with a local file header signature and ends with the EOCD signature. + assert_eq!(&zip[0..4], &0x0403_4b50u32.to_le_bytes()); + assert!(zip.len() > 22); + assert_eq!( + &zip[zip.len() - 22..zip.len() - 18], + &0x0605_4b50u32.to_le_bytes() + ); + // Every embedded file's path appears in the archive. + for file in FILES { + let needle = file.name.as_bytes(); + assert!( + zip.windows(needle.len()).any(|w| w == needle), + "bundle is missing {}", + file.name + ); + } + // The EOCD total-entry count equals the number of embedded files. + let count = u16::from_le_bytes([zip[zip.len() - 12], zip[zip.len() - 11]]); + assert_eq!(count as usize, FILES.len()); + } +} diff --git a/crates/server/src/round_engine.rs b/crates/server/src/round_engine.rs new file mode 100644 index 0000000..bdd6a6c --- /dev/null +++ b/crates/server/src/round_engine.rs @@ -0,0 +1,4034 @@ +//! The per-event, **round-driven engine** (race redesign Slice 3a) — the keystone that +//! wires the dormant format generators into a live, RD-driven flow. +//! +//! [`crate::events::RoundDef`] describes *what* a round runs (a format name, its config, +//! its eligible classes, a [`SeedingRule`](crate::events::SeedingRule)); this module turns +//! that definition into a running [`Generator`](gridfpv_engine::format::Generator) and +//! advances it **off the log**, exactly as [`gridfpv_engine::event::run_event`] does for a +//! whole event — but interactively, one [`Command::FillRound`](crate::control::Command) +//! at a time instead of in one wholesale sweep. +//! +//! # The engine is a pure function of the log + meta (RE §6) +//! +//! Nothing here persists engine state. A [`FillRound`](crate::control::Command::FillRound) +//! rebuilds the round's generator from the round's [`RoundDef`] + the event's +//! [`classes_membership`](crate::events::EventMeta::classes_membership) (the field) and +//! the round's **completed heats read back from the log**, then asks the generator what to +//! run next. So the same log + meta always yields the same next heat — the determinism the +//! generator contract demands (RE §6), and the same property +//! [`run_event`](gridfpv_engine::event::run_event) relies on when it replays a recorded +//! event. +//! +//! # How a round advances (mirrors the `run_event` loop) +//! +//! 1. **Build the field.** For a normal round the field is the eligible classes' membership +//! (union, in roster/seed order), mapped pilot→competitor. For a +//! [`SeedingRule::FromRanking`](crate::events::SeedingRule::FromRanking) round the field +//! is the **top-N** of a prior round's ranking — the qualifying→bracket *carry*, reusing +//! [`advance_top_n`](gridfpv_engine::format::advance_top_n) over that source round's +//! ranking exactly as [`run_event`](gridfpv_engine::event::run_event)'s phase-2 seeding +//! does. +//! 2. **Build the generator** for the field via +//! [`FormatRegistry::build`](gridfpv_engine::format::FormatRegistry::build). +//! 3. **Read the round's completed heats from the log** — every heat tagged +//! `round == round.id` whose result is final (it reached `Final`), scored under the +//! round's [`win_condition`](crate::events::RoundDef::win_condition). +//! 4. **`generator.next(&completed)`** → either emit a `HeatScheduled` per plan (tagged +//! with the round, and the class when the round is single-class) or surface *round +//! complete*. +//! +//! Because step 3 reads the log, the advance closes through the log: when a heat reaches +//! `Final` (the existing FSM path appends `HeatStateChanged { Finalized }`), the next +//! `FillRound` sees it as a completed heat and the generator advances — including across +//! the bracket carry, where the *source* round's completed heats produce the ranking the +//! bracket seeds from. + +use std::collections::{BTreeMap, BTreeSet}; + +use gridfpv_engine::format::{ + CompletedHeat, FormatConfig, FormatRegistry, GeneratorStep, RankEntry, advance_range, + advance_top_n, +}; +use gridfpv_engine::heat::{HeatState, heat_state}; +use gridfpv_engine::schedule::{Frequency, FrequencyPool, allocate}; +use gridfpv_engine::scoring::{HeatResult, Metric, WinCondition}; +use gridfpv_events::{ClassId, CompetitorRef, Event, HeatId, RoundId}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::events::{ChannelMode, EventMeta, RoundDef, SeedingRule}; +use crate::timers::{Timer, TimerRegistry}; + +/// The hard guard against a generator that never completes (mirrors +/// [`run_event`](gridfpv_engine::event::run_event)'s `max_heats`): a real format always +/// converges, so a round that has somehow already run this many heats is a logic bug, not +/// a request for another heat. `FillRound` returns it as "complete" rather than emitting an +/// unbounded heat. +const MAX_HEATS_PER_ROUND: usize = 1_000; + +/// The outcome of filling a round (race redesign Slice 3a) — the typed answer to a +/// [`Command::FillRound`](crate::control::Command::FillRound). +/// +/// Either the generator emitted the next heat (which the handler appends as a tagged +/// [`Event::HeatScheduled`]), or the round is finished — a *typed ok*, **not** an error +/// (an empty round is a legitimate, expected terminal state, not a failure). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FillOutcome { + /// The next heat to schedule for this round: its generated heat id and lineup. The + /// handler appends a `HeatScheduled` tagged with `round` (and `class` when single-class). + Scheduled { + /// The heat id the generator chose. + heat: HeatId, + /// The heat lineup, in the generator's seeding order. + lineup: Vec, + /// **Static-mode** per-pilot channel assignment (race redesign Slice 7a): `Some` for a + /// [`ChannelMode::Static`](crate::events::ChannelMode::Static) round (the channel-balanced + /// builder picks each pilot's *fixed* membership channel), `None` for a + /// [`PerHeat`](crate::events::ChannelMode::PerHeat) round (the handler then assigns channels + /// from the timer's pool via [`assign_for_event`], the prior behaviour). The handler still + /// enforces the node-count cap either way. + frequencies: Option>, + /// The round's **field draw to record** (freeze-at-fill, #334): `Some` exactly when + /// this is a carry-seeded round's FIRST scheduled heat and no draw is recorded yet. + /// The handler appends the [`Event::RoundFieldDrawn`] *before* the `HeatScheduled`, + /// freezing the resolution every later read replays. + field_draw: Option>, + }, + /// The round is complete — the generator returned + /// [`GeneratorStep::Complete`](gridfpv_engine::format::GeneratorStep::Complete). No + /// heat is appended; the round's final ranking is available via [`round_ranking`]. + Complete, + /// Every heat the generator wants right now is **already scheduled** and awaiting its + /// `Finalize`. No new heat is appended and the round is *not* finished — the RD just needs + /// to drive the outstanding heat before the next one can be drawn. A typed ok, not an + /// error. + AlreadyScheduled, +} + +/// An error filling a round: the round does not exist, the field is empty, the format is +/// unknown, or a `FromRanking` source round is missing/unscorable. Each maps to a +/// [`ProtocolError`](crate::error::ProtocolError) at the call site. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FillError { + /// No [`RoundDef`] with that id in the event's [`rounds`](EventMeta::rounds). + UnknownRound(String), + /// The round's field resolved empty (no class membership, or a `FromRanking` source + /// with no ranking yet) — there is nothing to schedule. + EmptyField(String), + /// The round's [`format`](RoundDef::format) is not a known + /// [`FormatRegistry::standard`] name (should have been caught at add/update; a + /// defensive check so a stale log can't panic). + UnknownFormat(String), + /// A [`SeedingRule::FromRanking`] names a source round (in its `source_rounds`) that does not + /// exist in this event. + UnknownSourceRound(String), + /// A [`ChannelMode::Static`](crate::events::ChannelMode::Static) round has a member with **no + /// assigned channel** (race redesign Slice 7a): static channel-balanced formation needs every + /// member to carry a fixed channel. The inner `String` names the pilot missing one. + MissingChannel(String), + /// Seeding resolution recursed past [`MAX_SEEDING_DEPTH`](crate::events::MAX_SEEDING_DEPTH) — + /// either a [`Combine`](crate::events::SeedingRule::Combine) nested too deeply, or a cross-round + /// seeding **cycle** (e.g. round A seeds `FromRanking` B while B seeds `FromRanking` A). A guard + /// that turns an otherwise-unbounded recursion / stack overflow into a typed `400`. + SeedingTooDeep, +} + +impl std::fmt::Display for FillError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FillError::UnknownRound(id) => write!(f, "no round with id {id:?} in this event"), + FillError::EmptyField(id) => { + write!( + f, + "round {id:?} has no field to schedule (empty membership)" + ) + } + FillError::UnknownFormat(fmt) => write!(f, "round uses unknown format {fmt:?}"), + FillError::UnknownSourceRound(id) => { + write!( + f, + "seeding source round {id:?} does not exist in this event" + ) + } + FillError::MissingChannel(pilot) => { + write!( + f, + "static-channel round member {pilot:?} has no assigned channel" + ) + } + FillError::SeedingTooDeep => { + write!( + f, + "seeding nesting too deep (a Combine nested too far, or a cross-round seeding cycle)" + ) + } + } + } +} + +impl std::error::Error for FillError {} + +/// Per-heat **channel assignment** failed (race redesign Slice 4a): the lineup exceeds the timer's +/// node/slot count (the heat-size cap), or there are too few available channels to seat it. +/// +/// A typed error the heat-build paths (`FillRound` / `ScheduleHeat`) surface as a `400` with a +/// clear message — a heat that cannot be seated on the timer must not be scheduled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AssignError { + /// The lineup is larger than the timer can physically run: `lineup` pilots, `nodes` slots. + TooManyForNodes { + /// Pilots in the heat's lineup. + lineup: usize, + /// The timer's node/slot count (the cap). + nodes: usize, + }, + /// The lineup fits the node count, but the timer's **available channels** are too few to give + /// every pilot a distinct channel: `lineup` pilots, `available` channels. + TooFewChannels { + /// Pilots in the heat's lineup. + lineup: usize, + /// Distinct available channels the timer offered. + available: usize, + }, +} + +impl std::fmt::Display for AssignError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AssignError::TooManyForNodes { lineup, nodes } => write!( + f, + "heat lineup of {lineup} exceeds the timer's {nodes} node(s)" + ), + AssignError::TooFewChannels { lineup, available } => write!( + f, + "the timer offers only {available} channel(s) for a {lineup}-pilot heat" + ), + } + } +} + +impl std::error::Error for AssignError {} + +/// Assign **video channels** to a heat's lineup from a timer's available channels (race redesign +/// Slice 4a; IMD-aware selection #209) — the engine's half of the RE §7.3 split (the engine +/// allocates; the adapter applies). +/// +/// Given the event's selected `timer` and the heat's `lineup` (in seed order): +/// +/// 1. **Heat-size cap.** The lineup must be ≤ the timer's +/// [`node_count`](crate::timers::Timer::node_count); otherwise [`AssignError::TooManyForNodes`]. +/// A timer with **no available channels** (a sim/Mock-without-frequencies, an unconfigured +/// timer) assigns **nothing** — an empty allocation — *after* the cap check, so a heat that is +/// simply un-channelled is fine but an oversized one is still rejected. +/// 2. **IMD-aware channel SELECTION (#209 auto-pick).** Rather than first-fitting the pool's first +/// `lineup.len()` channels, pick the **cleanest** size-`lineup.len()` *subset* of the timer's +/// available channels by third-order intermodulation +/// ([`pick_best_imd_set`](gridfpv_engine::imd::pick_best_imd_set)). IMD only matters for the +/// channels flying **simultaneously in this heat**, so the channel *set* is chosen for the +/// heat's lineup size — products landing on/near a used channel cause video breakup, so the +/// subset that keeps the worst product farthest from every used channel is chosen. Too few +/// available channels for the lineup is [`AssignError::TooFewChannels`]. +/// 3. **First-fit assignment of the chosen set.** The IMD-best channels (sorted ascending) are +/// laid onto the lineup in seed order via [`allocate`] — top seed gets the lowest chosen +/// channel, etc. — so the per-pilot mapping is deterministic. +/// +/// Pure and deterministic: the same lineup + timer config always yields the same per-pilot +/// `(competitor, mhz)` assignment — no clock, no RNG — the determinism `HeatScheduled.frequencies` +/// and the e2e rely on (the fill is replay-deterministic). +pub fn assign_frequencies( + timer: &Timer, + lineup: &[CompetitorRef], +) -> Result, AssignError> { + let nodes = timer.node_count as usize; + if lineup.len() > nodes { + return Err(AssignError::TooManyForNodes { + lineup: lineup.len(), + nodes, + }); + } + // No available channels ⇒ no channel assignment (sim/Mock-without-frequencies, unconfigured). + // The cap above still applies; only the channel step is skipped. + if timer.available_channels.is_empty() { + return Ok(Vec::new()); + } + // The candidate pool is the available channels, but never more than the timer has nodes for (a + // node can't run two channels). De-duplicate first so the IMD picker chooses among distinct + // channels and the TooFewChannels count below is the real distinct supply. + let mut pool: Vec = Vec::new(); + for &ch in timer.available_channels.iter().take(nodes) { + if !pool.contains(&ch) { + pool.push(ch); + } + } + if lineup.len() > pool.len() { + return Err(AssignError::TooFewChannels { + lineup: lineup.len(), + available: pool.len(), + }); + } + + // #209 auto-pick: choose the IMD-cleanest size-`lineup.len()` subset of the candidate pool for + // this heat's *simultaneous* lineup, then first-fit those channels onto the seed-ordered lineup. + // NOTE(#209): this is the **auto-pick** half only. The remaining halves stay on the roadmap — + // surfacing the heat's IMD score in the UI and flagging a low-IMD heat for the RD. Channels are + // now IMD-optimised at fill; the score display + low-IMD flag are not yet wired. + // `pick_best_imd_set` is deterministic (tie-broken by widest spread then lowest channels), so + // the assignment is replay-deterministic. A manual per-heat channel override (if any) is applied + // by the caller, which wins over this auto-pick. + let best = gridfpv_engine::imd::pick_best_imd_set(&pool, lineup.len()); + let chosen_pool = FrequencyPool::new(best.into_iter().map(Frequency::new)); + match allocate(lineup, &chosen_pool) { + Ok(assignment) => Ok(assignment + .into_iter() + .map(|(competitor, freq)| (competitor, freq.mhz)) + .collect()), + Err(e) => Err(AssignError::TooFewChannels { + lineup: e.needed, + available: e.available, + }), + } +} + +/// The timer a heat's channels are assigned from for an event (race redesign Slice 4a): the event's +/// **effective primary** timer (its override, else the first selected), resolved in `timers`. +/// +/// `None` when the event selects no timer, or the selected timer is not in the registry — the +/// caller then assigns no channels (an un-channelled heat, e.g. a pure-sim event). The effective +/// primary mirrors the source bridge's selection, so the channels a heat is assigned match the +/// timer it will actually fly on. +pub fn assignment_timer(meta: &EventMeta, timers: &TimerRegistry) -> Option { + let primary = meta.effective_primary()?; + timers.get(&primary) +} + +/// Assign channels to `lineup` for `meta`'s event using its effective primary timer (race redesign +/// Slice 4a). When the event has a resolvable timer, delegate to [`assign_frequencies`] (cap + +/// first-fit); when it has none, the heat carries no channels (an empty assignment — a pure-sim +/// event has no node cap beyond the format). +pub fn assign_for_event( + meta: &EventMeta, + timers: &TimerRegistry, + lineup: &[CompetitorRef], +) -> Result, AssignError> { + match assignment_timer(meta, timers) { + Some(timer) => assign_frequencies(&timer, lineup), + None => Ok(Vec::new()), + } +} + +/// Resolve a round by id in the event meta, or [`FillError::UnknownRound`]. +fn round_of<'a>(meta: &'a EventMeta, round: &RoundId) -> Result<&'a RoundDef, FillError> { + meta.rounds + .iter() + .find(|r| &r.id == round) + .ok_or_else(|| FillError::UnknownRound(round.0.clone())) +} + +/// Whether a round is **single-class** — exactly one eligible class, so its scheduled heats +/// are tagged with that class. A many/all-class (open / practice) round tags no class. +fn single_class(round: &RoundDef) -> Option { + match round.classes.as_slice() { + [only] => Some(only.clone()), + _ => None, + } +} + +/// Build a round's **field** as engine [`CompetitorRef`]s (race redesign Slice 3a) — the round's +/// eligible classes + its [`SeedingRule`] resolved by [`resolve_seeding`] at depth 0. See +/// [`resolve_seeding`] for the per-variant semantics (roster / ranking top-N / ranking slice / +/// heat-winners / combine / channels) and the recursion-depth guard. +fn round_field( + meta: &EventMeta, + round: &RoundDef, + events: &[Event], +) -> Result, FillError> { + round_field_at(meta, round, events, 0) +} + +/// Depth-carrying [`round_field`]: resolve the round's seeding at recursion `depth`. +/// +/// The public [`round_field`] enters at depth 0; the seeding resolver threads `depth` through every +/// cross-round / `Combine` hop so a too-deep `Combine` or a cross-round seeding **cycle** is caught +/// as [`FillError::SeedingTooDeep`] rather than overflowing the stack (see [`resolve_seeding`]). +fn round_field_at( + meta: &EventMeta, + round: &RoundDef, + events: &[Event], + depth: usize, +) -> Result, FillError> { + // FREEZE-AT-FILL (#334): a carry seeding's field is drawn ONCE — at the round's first + // fill — and recorded in the log; every later read replays the recorded draw. Live + // re-resolution let an adjudication on the SOURCE round, landing after this round had + // already raced, silently rewrite who this round's field "was" (raced results vanished + // from its ranking). Before the first fill there is no draw and resolution stays live — + // a build-ahead round keeps tracking its source until it actually fills. + if seeding_freezes(&round.seeding) { + if let Some(field) = recorded_field(events, &round.id) { + return Ok(field); + } + } + resolve_seeding(meta, &round.classes, &round.seeding, events, depth) +} + +/// Whether a [`SeedingRule`]'s resolution is **frozen at first fill** (issue #334). +/// +/// The carry seedings — those derived from another round's outcome — freeze: their meaning is +/// "the standings *as advanced*", a draw the RD saw and raced. Roster-derived seedings stay +/// live so a late entrant added to the class mid-round still joins the field/ranking. +fn seeding_freezes(seeding: &SeedingRule) -> bool { + !matches!( + seeding, + SeedingRule::FromRoster | SeedingRule::AllChannels { .. } + ) +} + +/// The round's **recorded field draw**, if one was frozen at fill ([`Event::RoundFieldDrawn`]). +/// Last one wins (a round refilled after a `Discard`-style reset re-records). +fn recorded_field(events: &[Event], round_id: &RoundId) -> Option> { + events.iter().rev().find_map(|event| match event { + Event::RoundFieldDrawn { round, field } if round == round_id => Some(field.clone()), + _ => None, + }) +} + +/// Resolve a [`SeedingRule`] to a round's **field** as engine [`CompetitorRef`]s (race redesign +/// Slice 3a; multi-main `FromRankingRange` / `Combine`). +/// +/// - [`SeedingRule::FromRoster`] (the default): the union of the eligible `classes`' +/// [`classes_membership`](EventMeta::classes_membership), in class-selection then membership +/// (roster/seed) order, de-duplicated so a pilot in two eligible classes appears once. +/// - [`SeedingRule::FromRanking`]: the **top-N** of the source rounds' best-per-pilot +/// [`aggregate_rankings`] (the qualifying→bracket carry, issue #51 multi-select). +/// - [`SeedingRule::FromRankingRange`]: a **slice** (`skip` / `take`) of that same merged ranking — +/// the multi-main / consolation carry (e.g. a C-main = qual seeds 13–20). +/// - [`SeedingRule::FromHeatWinners`]: the source (bracket-level) round's **heat winners**, in heat +/// order — the bracket advancement carry (decisions D13, #217). +/// - [`SeedingRule::Combine`]: the **union** of each sub-rule's resolved field, concatenated in +/// order and de-duplicated keeping each competitor's first occurrence — the multi-main composition +/// primitive. Each sub-rule resolves against the same `classes`. +/// +/// `depth` bounds recursion: it is incremented across each cross-round (`round_ranking` → +/// `resolve_seeding`) hop and each `Combine` level, and past +/// [`MAX_SEEDING_DEPTH`](crate::events::MAX_SEEDING_DEPTH) yields [`FillError::SeedingTooDeep`]. +fn resolve_seeding( + meta: &EventMeta, + classes: &[ClassId], + seeding: &SeedingRule, + events: &[Event], + depth: usize, +) -> Result, FillError> { + if depth > crate::events::MAX_SEEDING_DEPTH { + return Err(FillError::SeedingTooDeep); + } + match seeding { + SeedingRule::FromRoster => { + let mut field: Vec = Vec::new(); + for class in classes { + if let Some(membership) = meta.classes_membership.iter().find(|m| &m.class == class) + { + for slot in &membership.pilots { + let competitor = CompetitorRef(slot.pilot.0.clone()); + if !field.contains(&competitor) { + field.push(competitor); + } + } + } + } + Ok(field) + } + SeedingRule::FromRanking { + source_rounds, + top_n, + } => { + let merged = merged_source_ranking(meta, source_rounds, events, depth)?; + Ok(advance_top_n(&merged, *top_n)) + } + // The multi-main / consolation carry: the same merged best-per-pilot ranking as + // `FromRanking`, but seeded from the window `skip+1 ..= skip+take` rather than the top-N. + SeedingRule::FromRankingRange { + source_rounds, + skip, + take, + } => { + let merged = merged_source_ranking(meta, source_rounds, events, depth)?; + Ok(advance_range(&merged, *skip, *take)) + } + // Bracket advancement (decisions D13, #217): the field is the source level's **heat + // winners** — the competitors that advanced out of each heat, in heat order. This is how a + // single-elimination bracket advances round-to-round under the level-per-round model: the + // next level is a new round seeded from the prior level's winners. + SeedingRule::FromHeatWinners { source_round } => { + let source = round_of(meta, source_round) + .map_err(|_| FillError::UnknownSourceRound(source_round.0.clone()))?; + heat_winners(meta, source, events, depth) + } + // The multi-main composition primitive: resolve each sub-rule against the same `classes`, + // concatenate the fields in order, and de-duplicate keeping each competitor's **first** + // occurrence — so a competitor two sub-sources both name is seeded once, at the earlier + // source's position. Each sub-rule resolves one level deeper (bounding the nesting). + SeedingRule::Combine { sources } => { + let mut field: Vec = Vec::new(); + for sub in sources { + for competitor in resolve_seeding(meta, classes, sub, events, depth + 1)? { + if !field.contains(&competitor) { + field.push(competitor); + } + } + } + Ok(field) + } + // Open practice (open-practice format): the field is the active **channels**, each node + // index laid out as a `node-{i}` competitor ref (the timer-seat handle) in the given order. + // No pilots, no membership — laps are tracked per channel live in memory (not logged). + SeedingRule::AllChannels { channels } => Ok(channels + .iter() + .map(|i| CompetitorRef(format!("node-{i}"))) + .collect()), + } +} + +/// The **merged best-per-pilot ranking** across `source_rounds` — the shared front of the +/// `FromRanking` / `FromRankingRange` carries. Each source round's provisional-or-final ranking is +/// computed (one level deeper, so the depth guard bounds cross-round cycles) then merged via +/// [`aggregate_rankings`]. A source id not in this event is [`FillError::UnknownSourceRound`]. +fn merged_source_ranking( + meta: &EventMeta, + source_rounds: &[RoundId], + events: &[Event], + depth: usize, +) -> Result, FillError> { + let mut rankings: Vec> = Vec::with_capacity(source_rounds.len()); + for source_id in source_rounds { + let source = round_of(meta, source_id) + .map_err(|_| FillError::UnknownSourceRound(source_id.0.clone()))?; + rankings.push(round_ranking_at(meta, source, events, depth + 1)?); + } + Ok(aggregate_rankings(&rankings)) +} + +/// The **heat winners** of a (bracket-level) source round, in heat order — the field a +/// [`SeedingRule::FromHeatWinners`] successor level seeds from (decisions D13, #217). +/// +/// The winners are the source format's **advancing set** — [`Generator::advancers`], each heat's +/// top `advance` finishers in heat order, then any byes. This carries forward however many heats the +/// level had — head-to-head advances one per heat, a 4-up heat advances two — so the next level's +/// size follows the bracket rather than a fixed top-N. Using the generator's advancers (rather than +/// a ranking-position filter) is what makes a **4-up** level carry correctly: its two heat losers +/// rank at *distinct* in-heat positions (3rd, 4th), so they do not share one worst band and a +/// "better than the worst position" filter would wrongly keep the 3rd-place losers too. +/// +/// Before the source level is complete its advancers are provisional (the carry recomputes +/// deterministically as the source level's heats finalize — the same off-the-log property +/// [`FromRanking`](SeedingRule::FromRanking) has). A source round that advances no one (one +/// competitor, or none finalized yet) yields an empty field. +fn heat_winners( + meta: &EventMeta, + source: &RoundDef, + events: &[Event], + depth: usize, +) -> Result, FillError> { + // The carry is the source format's **advancing set** — `Generator::advancers`, not a + // ranking-position heuristic. `HeadToHead` overrides it to return each heat's winner(s) in + // heat order (the default position-`< worst` filter wrongly kept the better-placed losers, + // since a level's eliminated pilots do not share one worst band — the losing-semifinalist + // bug). Built from the same field + log as `round_ranking_at`, one depth deeper (the + // seeding-cycle guard). + let field = round_field_at(meta, source, events, depth + 1)?; + let registry = FormatRegistry::standard(); + let generator = registry + .build(&source.format, &format_config(source, field)) + .ok_or_else(|| FillError::UnknownFormat(source.format.clone()))?; + let completed = completed_heats(source, events); + Ok(generator.advancers(&completed)) +} + +/// Merge several rounds' rankings into **one aggregated ranking, best-per-pilot** (issue #51 +/// multi-select seeding). +/// +/// The aggregation rule: each pilot's standing in the merged ranking is their **best (lowest) +/// 1-based position across the source rounds they appear in** — e.g. a pilot who placed 3rd in one +/// qualifying round and 1st in another is seeded as a 1. A pilot is included if they ranked in *any* +/// source round; a pilot absent from a round simply does not contribute a position from it. The +/// merged competitors are then ordered by that best position (ascending), ties broken by +/// [`CompetitorRef`] string for a **total, deterministic** order (so the same logs always yield the +/// same seeding), and re-numbered with the same dense, tie-aware "1, 2, 2, 4" convention as a single +/// round's ranking. For a single source round this reproduces that round's ranking unchanged (modulo +/// the deterministic ref tie-break, which a single ranking already satisfies). +/// +/// "Best position" — rather than summing points or averaging — keeps the seam **simple and +/// monotonic**: seeding a bracket from multiple qualifiers rewards a pilot's strongest qualifying +/// result, the usual "your best run carries you into the bracket" semantics, without depending on +/// how many rounds each pilot happened to enter. +fn aggregate_rankings(rankings: &[Vec]) -> Vec { + // Best (lowest) position seen per competitor across all source rounds. + let mut best: BTreeMap = BTreeMap::new(); + for ranking in rankings { + for entry in ranking { + best.entry(entry.competitor.clone()) + .and_modify(|p| *p = (*p).min(entry.position)) + .or_insert(entry.position); + } + } + + // Order by best position, then by competitor ref — a total, deterministic order. The BTreeMap + // already yields ref order, so a stable sort by position alone keeps refs as the tie-break. + let mut rows: Vec<(CompetitorRef, u32)> = best.into_iter().collect(); + rows.sort_by(|(a_ref, a_pos), (b_ref, b_pos)| { + a_pos.cmp(b_pos).then_with(|| a_ref.0.cmp(&b_ref.0)) + }); + + // Re-number with dense, tie-aware positions (1, 2, 2, 4): rows sharing a best position share a + // merged position, the next distinct row skips past them. + let mut merged = Vec::with_capacity(rows.len()); + let mut position = 0u32; + let mut prev_best: Option = None; + for (index, (competitor, best_pos)) in rows.into_iter().enumerate() { + if prev_best != Some(best_pos) { + position = index as u32 + 1; + prev_best = Some(best_pos); + } + merged.push(RankEntry { + competitor, + position, + }); + } + merged +} + +/// Whether `round` is an **open-practice** round (open-practice format): `format == +/// "open_practice"` with [`SeedingRule::AllChannels`] seeding (race redesign open-practice Slice 1). +/// +/// The source bridge resolves a running heat's round through this so it routes the heat's passes to +/// the in-memory per-channel accumulator (not the log); the field builder lays the channels out as +/// `node-{i}` refs. The format name *and* the seeding are both checked so a mis-tagged round (one or +/// the other but not both) is treated as a normal round, never half-open-practice. +pub fn is_open_practice(round: &RoundDef) -> bool { + round.format == gridfpv_engine::format::OpenPractice::NAME + && matches!(round.seeding, SeedingRule::AllChannels { .. }) +} + +/// Whether `round` is an **open-ended `Static` round** (release-hardening P1-8): a +/// [`ChannelMode::Static`] round whose `rounds` param is `0` (an unbounded "heats per pilot"). +/// +/// Such a round's [`fill_round_static`] generator **never** reports `Complete` — it manufactures +/// the next heat on demand forever — so a `FillMode::All` batch fill would loop to the defensive cap +/// (logging a spurious "generator bug" warning) instead of converging. The handler uses this to +/// reject `FillMode::All` for the round and steer the RD to single-step fills instead. +pub fn is_open_ended_static(round: &RoundDef) -> bool { + round.channel_mode == ChannelMode::Static && static_round_count(round) == 0 +} + +/// The **round-scoped** heat id for an open-practice round (issue #54): `"-heat"`. +/// +/// The open-practice format generator emits a fixed `"open-practice"` heat id, so two open-practice +/// rounds in one event would both try to auto-create a heat under that one id and the second round +/// would get no distinct heat. Deriving the id from the round id makes each open-practice round's +/// heat unique while staying deterministic (the same round id always yields the same heat id, so the +/// auto-create is idempotent per round). +fn open_practice_heat_id(round_id: &RoundId) -> HeatId { + HeatId(format!("{}-heat", round_id.0)) +} + +/// Build a [`FormatConfig`] for a round over `field`: the round's +/// [`params`](RoundDef::params) verbatim, identity seeding (the field is already in seed +/// order — the membership/carry decided it), and no recorded draw. +/// +/// For the **qualifying formats** (`timed_qual` / `round_robin`) the cross-round ranking +/// **metric is derived from the round's [`win_condition`](RoundDef::win_condition)** rather +/// than from a separately-stored `metric` param — the qualifying metric *is* the win +/// condition, so the win condition is the single source of truth (Rounds form redesign: +/// qualifying metric is the win condition). The derived `metric` param is injected into the +/// config (overriding any stale stored value), so the generators' existing `from_config` +/// readers see the win-condition-derived metric. A non-qualifying format keeps its params +/// verbatim. See [`qual_metric_for`]. +fn format_config(round: &RoundDef, field: Vec) -> FormatConfig { + let mut config = FormatConfig::new(field); + config.params = round.params.clone(); + if let Some(metric) = qual_metric_for(&round.format, round.win_condition) { + config + .params + .insert("metric".to_string(), metric.to_string()); + } + config +} + +/// The qualifying-generator **`metric` param derived from a round's win condition** (Rounds form +/// redesign: the qualifying metric *is* the win condition), or `None` for a non-qualifying format +/// (whose params are taken verbatim). +/// +/// The win condition is the single source of truth for how the qualifying ranking aggregates: +/// +/// - `timed_qual` ([`QualMetric`](gridfpv_engine::timed_qual::QualMetric)): +/// - [`WinCondition::BestLap`] → `"best-lap"` (fastest single lap), +/// - [`WinCondition::BestConsecutive`] → `"best-consecutive"` (fastest N-lap window), +/// - [`WinCondition::Timed`] (Most Laps) → `"most-laps"`, +/// - [`WinCondition::FirstToLaps`] is **not** a qualifying metric → the default `"best-lap"`. +/// - `round_robin` (`RrMetric`, a carved-out format kept only as an inert string arm): +/// - [`WinCondition::Timed`] (Most Laps) → `"total-laps"`, +/// - every other condition → the default `"points"` standing. +fn qual_metric_for( + format: &str, + win_condition: gridfpv_engine::scoring::WinCondition, +) -> Option<&'static str> { + use gridfpv_engine::scoring::WinCondition as WC; + match format { + "timed_qual" => Some(match win_condition { + WC::BestConsecutive { .. } => "best-consecutive", + WC::Timed { .. } => "most-laps", + // Best lap and the non-qualifying First-to-N both fall to the fast-lap default. + WC::BestLap | WC::FirstToLaps { .. } => "best-lap", + }), + "round_robin" => Some(match win_condition { + WC::Timed { .. } => "total-laps", + // Best lap / best-consecutive / first-to-N all rank by the points standing. + _ => "points", + }), + _ => None, + } +} + +/// The completed heats of a round, **read back from the log** and scored under the round's +/// [`win_condition`](RoundDef::win_condition) (race redesign Slice 3a). +/// +/// A heat counts as completed when it was scheduled tagged with this round *and* its folded +/// [`HeatState`] is [`Final`](HeatState::Final) (the FSM terminal the `Finalize` command +/// reaches). Each is scored over its **full adjudicated event window** (passes *and* every +/// marshaling adjudication — DQ / time / throw-out / void / lap-edit) via the one shared +/// [`score_heat_window`](crate::app::score_heat_window) the per-heat result projection uses, so +/// the standings can never disagree with the heat page on an adjudicated heat (#226). The order +/// is the order the heats were first scheduled, which is the order the generator emitted them — +/// so the history fed to [`Generator::next`](gridfpv_engine::format::Generator::next) matches +/// what [`run_format`](gridfpv_engine::event::run_format) accumulated. +pub fn completed_heats(round: &RoundDef, events: &[Event]) -> Vec { + finalized_heat_ids(round, events) + .into_iter() + .map(|heat| { + // Score over the heat's FULL adjudicated window with PRESERVED global offsets — the + // SAME path the per-heat result projection (`app.rs` `HeatProjection::Result`) uses, so + // an adjudication that moves the heat page moves the standings too (#226). The previous + // pass-only `score_marshaled` discarded every adjudication, leaving the raw on-track + // score here while the heat page showed the corrected one — the split-brain this closes. + let result = crate::app::score_heat_window( + events, + &heat, + round.win_condition, + crate::app::min_lap_micros_of(Some(round)), + ); + // The generator keys `next`/`ranking` on the heat ids it **emitted**; the log carries + // the round-scoped id, so strip the scope back off before handing history to the + // generator (and to every ranking consumer keyed on generator ids). + let generator_id = unscope_heat_id(round, &heat); + CompletedHeat::new(generator_id, result) + }) + // A VOIDED heat's result counts for NOTHING downstream: the RD's "Void heat" (false + // start, timer glitch) must drop the heat from round ranking, standings, class points, + // heat winners, and any dependent round's seeding — exactly as if it had not been + // finalized. (The heat page still shows the voided result, flagged.) Because the void + // is folded from the adjudicated window, a RulingReversed on the void brings the heat + // back. Note the round then reads as incomplete until the heat is re-run or the void + // reversed — that is honest, not a bug. + .filter(|completed| !completed.result.voided) + .collect() +} + +/// A generator heat id scoped to its round — the id actually logged in `HeatScheduled`. +/// +/// Generator ids are only unique within a round, so the log carries `{round}-{generator-id}` +/// (deterministic: the same round + plan always yields the same logged id). +fn scoped_heat_id(round_id: &RoundId, generator_id: &HeatId) -> HeatId { + HeatId(format!("{}-{}", round_id.0, generator_id.0)) +} + +/// Map a round's **logged** heat id back to the **generator's** id (the one the format emitted). +/// +/// Strips this round's `{round}-` scope prefix. A logged id without the prefix passes through +/// verbatim — that covers events persisted before scoping (their in-flight rounds keep +/// matching their generators' raw ids) and the manually-scheduled heats a round never +/// generated. +fn unscope_heat_id(round: &RoundDef, heat: &HeatId) -> String { + heat.0 + .strip_prefix(&format!("{}-", round.id.0)) + .map(str::to_owned) + .unwrap_or_else(|| heat.0.clone()) +} + +/// The **finalized** heats of a round, in first-scheduled order — the heat ids both +/// [`completed_heats`] (which scores them) and the standings best-lap fold (which laps them) iterate. +/// +/// A heat counts when it was scheduled tagged with this round *and* its folded +/// [`HeatState`] is [`Final`](HeatState::Final). The order is the order the heats were first +/// scheduled (repeated schedules of the same id are deduped to their first appearance), matching +/// the generator's emission order. +fn finalized_heat_ids(round: &RoundDef, events: &[Event]) -> Vec { + let mut tagged: Vec = Vec::new(); + for event in events { + if let Event::HeatScheduled { + heat, + round: Some(r), + .. + } = event + { + if r == &round.id && !tagged.contains(heat) { + tagged.push(heat.clone()); + } + } + } + tagged + .into_iter() + .filter(|heat| heat_state(events, heat) == Some(HeatState::Final)) + .collect() +} + +/// Every competitor's **best (fastest) lap** (µs) across a round's finalized heats, keyed by +/// source-local [`CompetitorRef`] — the standings best-lap source. +/// +/// For each of the round's [`completed_heats`] this keeps the smallest +/// [`Placement::best_lap_micros`](gridfpv_engine::scoring::Placement::best_lap_micros) per +/// competitor — the fastest single lap the **adjudicated** scoring computed (thrown-out / voided +/// laps already excluded, inserted / adjusted laps already included), *independent* of the round's +/// win condition. Sourcing it from the same adjudicated heat result the standings rank over (rather +/// than the raw lap list) means a thrown-out lap no longer counts as a pilot's best (#226). A +/// competitor with no completed lap across the round is absent from the map. +fn round_best_laps(round: &RoundDef, events: &[Event]) -> BTreeMap { + let mut best: BTreeMap = BTreeMap::new(); + for heat in completed_heats(round, events) { + for place in &heat.result.places { + // A DISQUALIFIED placement contributes nothing: the DQ voids the heat's laps for + // that pilot, so a lap flown in it must not stand as their best (#339 — the ranking + // already excludes the DQ'd placement, #331; the displayed metrics must match). + // The pilot's other, clean heats still count. + if place.disqualified { + continue; + } + if let Some(lap) = place.best_lap_micros { + best.entry(place.competitor.competitor.clone()) + .and_modify(|existing| *existing = (*existing).min(lap)) + .or_insert(lap); + } + } + } + best +} + +/// The round's current **ranking** (race redesign Slice 3a): build the round's generator and +/// ask it for the ranking over the round's completed heats — provisional mid-round, final +/// once the round is [`Complete`](FillOutcome::Complete). This is what a `FromRanking` +/// successor round seeds from (the bracket carry). +pub fn round_ranking( + meta: &EventMeta, + round: &RoundDef, + events: &[Event], +) -> Result, FillError> { + round_ranking_at(meta, round, events, 0) +} + +/// Depth-carrying [`round_ranking`]: build the round's ranking with the seeding recursion `depth` +/// threaded through, so a `FromRanking` / `FromRankingRange` / `FromHeatWinners` source — itself a +/// cross-round seeding hop into [`round_field_at`] — stays bounded by the depth guard (a cross-round +/// seeding cycle returns [`FillError::SeedingTooDeep`] instead of overflowing the stack). +fn round_ranking_at( + meta: &EventMeta, + round: &RoundDef, + events: &[Event], + depth: usize, +) -> Result, FillError> { + let field = round_field_at(meta, round, events, depth)?; + let registry = FormatRegistry::standard(); + let generator = registry + .build(&round.format, &format_config(round, field)) + .ok_or_else(|| FillError::UnknownFormat(round.format.clone()))?; + let completed = completed_heats(round, events); + Ok(generator.ranking(&completed)) +} + +// --- Per-round standings (time-trial / qual display) ---------------------------------------- + +/// The **win-condition metric** a round's standings are ranked by — the tagged mirror of the +/// round's [`win_condition`](RoundDef::win_condition), carrying the value the ranking is *by*. +/// +/// This is the headline number the Rounds stage shows next to each pilot: the fastest single lap +/// ([`BestLap`](RoundMetric::BestLap)), the fastest N-consecutive-lap window +/// ([`BestConsecutive`](RoundMetric::BestConsecutive)), or the most laps banked +/// ([`MostLaps`](RoundMetric::MostLaps)) — mapped from the win condition exactly as +/// [`qual_metric_for`] derives the qualifying metric. The lap-time variants carry `None` for a +/// pilot who set no qualifying value (no lap / fewer than `n` laps), so a no-show still renders. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum RoundMetric { + /// [`WinCondition::BestLap`] (and the non-qualifying [`WinCondition::FirstToLaps`]): the pilot's + /// fastest single lap across the round, in microseconds, or `None` if they completed no lap. + BestLap { + /// Fastest single lap (µs), or `None` for no completed lap. + #[ts(type = "number | null")] + micros: Option, + }, + /// [`WinCondition::BestConsecutive`]: the pilot's smallest sum of `n` consecutive laps across + /// the round, in microseconds, or `None` if they never completed `n` consecutive laps. + BestConsecutive { + /// How many consecutive laps the window spans. + n: u32, + /// Smallest consecutive-window sum (µs), or `None` if fewer than `n` laps. + #[ts(type = "number | null")] + micros: Option, + }, + /// [`WinCondition::Timed`] (Most Laps): the most laps the pilot banked in any single heat. + MostLaps { + /// Most laps in a heat (0 if the pilot completed no lap). + laps: u32, + }, +} + +/// One pilot's **per-round standing** for the Rounds stage's time-trial (timed_qual) display. +/// +/// Built by [`round_standings`] for a single round: each pilot's [`position`](RoundStanding::position) +/// (exactly the [`round_ranking`] order, so the standings and the ranking never disagree), their +/// **best single lap** ([`best_lap_micros`](RoundStanding::best_lap_micros), *always* computed so the +/// UI can show a Best-lap column regardless of win condition), their **most laps in a heat** +/// ([`laps`](RoundStanding::laps)), and the win-condition [`metric`](RoundStanding::metric) the +/// ranking is by. Pure + deterministic — the same log + meta always yields the same standings. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct RoundStanding { + /// The competitor this standing is for (the pilot's source-local handle). + pub competitor: CompetitorRef, + /// 1-based position; tied competitors share a position with the same dense, tie-aware + /// "1, 2, 2, 4" convention as [`RankEntry`] — and exactly matches [`round_ranking`]. + pub position: u32, + /// The pilot's **best single lap** across the round's heats, in microseconds, or `None` when + /// they completed no lap. *Always* computed (independent of the win condition) so a Best-lap + /// column can sit alongside the ranking metric. + #[ts(type = "number | null")] + pub best_lap_micros: Option, + /// The most laps the pilot banked in any single heat (under the round's win condition) — the + /// most-laps metric value and lap-count context. `0` for a pilot who completed no lap. + pub laps: u32, + /// The win-condition metric the ranking is *by* (the tagged mirror of the round's win + /// condition). `best_lap_micros` is always present alongside this. + pub metric: RoundMetric, +} + +/// A round's **standings** for the time-trial (timed_qual) display (designed for it, sensible for +/// any format): one [`RoundStanding`] per pilot, in [`round_ranking`] order. +/// +/// For each pilot it computes, across the round's [`completed_heats`] (scored under the round's +/// [`win_condition`](RoundDef::win_condition)): +/// +/// - **`best_lap_micros`** — their fastest single lap, folded from the same finalized heats via the +/// lap-list projection ([`round_best_laps`]), *independent* of the win condition (so a most-laps +/// round still shows a real best lap). Always present. +/// - **`laps`** — the most laps they banked in any single heat (the win-condition lap count). +/// - **`metric`** — the value the ranking is by, built from the win condition like +/// [`qual_metric_for`]: [`BestConsecutive`](WinCondition::BestConsecutive) → the smallest +/// consecutive-window sum across their heats; [`Timed`](WinCondition::Timed) → most laps; +/// [`BestLap`](WinCondition::BestLap) / [`FirstToLaps`](WinCondition::FirstToLaps) → the best lap. +/// - **`position`** — reused straight from [`round_ranking`] (`generator.ranking(completed)`), so the +/// standings positions are byte-for-byte the ranking's, including the whole-field seeding (a no-show +/// still appears, ranked last, with a null metric). +/// +/// Pure and deterministic — the same log + meta always yields the same standings. +pub fn round_standings( + meta: &EventMeta, + round: &RoundDef, + events: &[Event], +) -> Result, FillError> { + // Positions come straight from the ranking, so standings + ranking can never disagree — and the + // whole-field seeding (no-shows ranked last) is inherited for free. + let ranking = round_ranking(meta, round, events)?; + // The round's scored heats (the same view the ranking ranked over) for the win-condition metric + // + lap counts, and the lap-list best single lap per pilot (win-condition-independent). + let completed = completed_heats(round, events); + let best_laps = round_best_laps(round, events); + + // Per-pilot aggregates across the round's heats: most laps in a heat, and the smallest + // best-consecutive window sum (when the round is scored under BestConsecutive). + let mut most_laps: BTreeMap = BTreeMap::new(); + let mut best_consec: BTreeMap = BTreeMap::new(); + for heat in &completed { + for place in &heat.result.places { + // A DISQUALIFIED placement contributes NO metric to the standings row (#339): the + // ranking already excludes it (#331), so the row must not keep surfacing the DQ'd + // heat's lap count / consecutive window next to a position that ignored them. + // The pilot's other, clean heats still aggregate. + if place.disqualified { + continue; + } + let competitor = place.competitor.competitor.clone(); + most_laps + .entry(competitor.clone()) + .and_modify(|m| *m = (*m).max(place.laps)) + .or_insert(place.laps); + if let Metric::BestConsecutiveMicros(Some(sum)) = place.metric { + best_consec + .entry(competitor) + .and_modify(|s| *s = (*s).min(sum)) + .or_insert(sum); + } + } + } + + Ok(ranking + .into_iter() + .map(|entry| { + let competitor = entry.competitor; + let best_lap_micros = best_laps.get(&competitor).copied(); + let laps = most_laps.get(&competitor).copied().unwrap_or(0); + let metric = match round.win_condition { + WinCondition::BestConsecutive { n } => RoundMetric::BestConsecutive { + n, + micros: best_consec.get(&competitor).copied(), + }, + WinCondition::Timed { .. } => RoundMetric::MostLaps { laps }, + // Best lap and the non-qualifying First-to-N both display the best single lap. + WinCondition::BestLap | WinCondition::FirstToLaps { .. } => RoundMetric::BestLap { + micros: best_lap_micros, + }, + }; + RoundStanding { + competitor, + position: entry.position, + best_lap_micros, + laps, + metric, + } + }) + .collect()) +} + +// --- Per-class standings (race redesign Slice 5/6a) ----------------------------------------- + +/// One pilot's **contribution to a class's standings** (race redesign Slice 5/6a) — the +/// per-pilot / per-class row aggregated across that class's rounds (the season-join shape). +/// +/// This is the row the Results UI renders per competitor: their final standing position, the +/// total points they accrued across the class's rounds, and the headline lap metrics (best lap, +/// total counted laps). It is a pure aggregate of the class's scored rounds, so it replays +/// identically off the same log + meta. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct ClassStanding { + /// The competitor this standing is for (the pilot's source-local handle). + pub competitor: CompetitorRef, + /// 1-based overall standing position; tied competitors share a position with the same + /// dense, tie-aware "1, 2, 2, 4" convention as [`RankEntry`]. + pub position: u32, + /// Total **points** across the class's rounds — the sum of each round's per-pilot points, + /// where a round awards `field_size - round_position + 1` points (a win in an N-pilot round + /// is worth N, last is worth 1). The headline metric the standings rank on. + pub points: u32, + /// The competitor's **best lap** across every heat of the class's rounds, in microseconds, + /// or `None` when they completed no lap. The qualifying-style tie-break / display metric. + #[ts(type = "number | null")] + pub best_lap_micros: Option, + /// The **total counted laps** the competitor completed across the class's rounds (each + /// round's laps under that round's win condition). A display / secondary metric. + pub total_laps: u32, + /// How many of the class's rounds this competitor appeared in (was ranked in) — context for + /// the points total (a pilot who skipped a round has fewer rounds to accrue from). + pub rounds_entered: u32, +} + +/// A class's **standings** (race redesign Slice 5/6a): the ordered per-pilot rows aggregated +/// across the class's rounds, plus the class id they are for. +/// +/// The season-join projection the Results screen reads: [`class_standings`] folds the log + meta +/// into one [`ClassStanding`] per competitor that raced the class, best standing first. Pure and +/// deterministic — the same log + meta always yields the same ordered standings. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct ClassStandings { + /// The class these standings are for. + pub class: ClassId, + /// The per-pilot standings, best first (ties adjacent, sharing a position). + pub standings: Vec, +} + +/// The per-competitor running totals accumulated while folding a class's rounds. +#[derive(Default)] +struct StandingAcc { + points: u32, + best_lap_micros: Option, + total_laps: u32, + rounds_entered: u32, +} + +impl StandingAcc { + /// Fold one round's contribution for a competitor: their `points` from this round, their + /// counted `laps`, and their `best_lap` (µs) if any. `best_lap_micros` keeps the smaller. + fn add_round(&mut self, points: u32, laps: u32, best_lap: Option) { + self.points += points; + self.total_laps += laps; + self.rounds_entered += 1; + if let Some(lap) = best_lap { + self.best_lap_micros = Some(match self.best_lap_micros { + Some(existing) => existing.min(lap), + None => lap, + }); + } + } +} + +/// Fold the log's **standings points adjustments** (marshaling Slice 6) for the heats in +/// `class_heats` into a per-competitor signed delta: every +/// [`Penalty::PointsDeducted`](gridfpv_events::Penalty::PointsDeducted) subtracts and +/// [`PointsAdded`](gridfpv_events::Penalty::PointsAdded) adds, **unless** the +/// [`PenaltyApplied`](Event::PenaltyApplied) that carried it was reversed by a +/// [`RulingReversed`](Event::RulingReversed) targeting its offset. +/// +/// Points penalties are **standings-only** (marshaling.html §3.3): they never touch the per-heat +/// lap result (the heat scorer ignores them), so this is the *one* place they land. They are +/// **scoped to the class** by `class_heats` — only a penalty recorded against a heat that belongs +/// to *this* class's rounds counts, so a points deduction in one class's heat does not leak into a +/// pilot's standings in another class they also race. Pure and order-independent — reversals are +/// gathered first, so a reversal preceding or following its penalty drops it the same way, +/// mirroring the heat scorer's adjudication fold. The delta is signed (`i64`) so additions and +/// deductions net out; the caller saturates the final total at zero. +fn points_adjustments( + events: &[Event], + class_heats: &BTreeSet, +) -> BTreeMap { + use gridfpv_events::Penalty; + + // The offsets every `RulingReversed` targets — a points penalty at one of these is dropped. + let reversed: BTreeSet = events + .iter() + .filter_map(|e| match e { + Event::RulingReversed { target } => Some(target.0), + _ => None, + }) + .collect(); + + let mut deltas: BTreeMap = BTreeMap::new(); + for (offset, event) in events.iter().enumerate() { + if reversed.contains(&(offset as u64)) { + continue; // this ruling was reversed — it contributes nothing + } + if let Event::PenaltyApplied { + heat, + competitor, + penalty, + } = event + { + // Only penalties recorded against this class's heats adjust this class's standings. + if !class_heats.contains(heat) { + continue; + } + match penalty { + Penalty::PointsDeducted { points } => { + *deltas.entry(competitor.clone()).or_default() -= i64::from(*points); + } + Penalty::PointsAdded { points } => { + *deltas.entry(competitor.clone()).or_default() += i64::from(*points); + } + // DQ / time penalties are per-heat (the heat scorer applies them); not standings. + Penalty::Disqualify { .. } | Penalty::TimeAdded { .. } => {} + } + } + } + deltas +} + +/// The set of heat ids that belong to `class`'s rounds — the heats whose adjudications scope to +/// this class's standings. A heat belongs when it was scheduled tagged with a round eligible for +/// `class` (the same round set [`class_standings`] aggregates), or tagged directly with `class`. +fn class_heat_ids(meta: &EventMeta, class: &ClassId, events: &[Event]) -> BTreeSet { + let class_rounds: BTreeSet<&gridfpv_events::RoundId> = rounds_for_class(meta, class) + .iter() + .map(|r| &r.id) + .collect(); + events + .iter() + .filter_map(|e| match e { + Event::HeatScheduled { + heat, + class: c, + round: r, + .. + } if c.as_ref() == Some(class) + || r.as_ref().is_some_and(|r| class_rounds.contains(r)) => + { + Some(heat.clone()) + } + _ => None, + }) + .collect() +} + +/// The rounds of `meta` whose eligible classes **include** `class` — the class's rounds, in the +/// order they are defined on the event. A round shared by several classes (an open / practice +/// round) contributes to each of its classes' standings. +fn rounds_for_class<'a>(meta: &'a EventMeta, class: &ClassId) -> Vec<&'a RoundDef> { + // `crate::scope::ClassId` is a re-export of `gridfpv_events::ClassId`, so the round's eligible + // classes and the queried class are the one type — a direct membership test. + meta.rounds + .iter() + .filter(|r| r.classes.contains(class)) + .collect() +} + +/// One competitor's **counted laps** across a heat's [`HeatResult`] (0 when absent). +fn placement_laps(result: &HeatResult, competitor: &CompetitorRef) -> u32 { + result + .places + .iter() + .find(|p| &p.competitor.competitor == competitor) + // A DISQUALIFIED placement banks no laps — the DQ voided the result those laps + // decided (#339; `round_standings` and `round_best_laps` already skip them, and the + // class total must agree with the round rows it aggregates). + .filter(|p| !p.disqualified) + .map(|p| p.laps) + .unwrap_or(0) +} + +/// Aggregate a **class's standings** across its rounds (race redesign Slice 5/6a) — the +/// season-join projection the Results screen reads. +/// +/// For every round whose eligible classes include `class`, this: +/// +/// 1. Computes that round's **ranking** via [`round_ranking`] (the same provisional-or-final +/// ranking the engine seeds `FromRanking` from), giving each entrant a round position over a +/// field of `field_size`. +/// 2. Awards **points** = `field_size - position + 1` (a win is worth the field size, last is +/// worth 1), and reads each entrant's **laps** / **best lap** off the round's scored heats +/// (each heat scored under the round's [`win_condition`](RoundDef::win_condition) via the same +/// [`completed_heats`] the engine ranks from). +/// 3. Accumulates per competitor: total points, the minimum best lap, total counted laps, and the +/// count of rounds entered. +/// +/// The accumulated rows are then ordered **by points (descending)**, ties broken by best lap +/// (faster first, a competitor with no lap last), then by competitor ref for a total, deterministic +/// order. `position` is assigned 1-based and tie-aware (equal points *and* best lap share a +/// position). Pure and deterministic: the same log + meta always yields the same standings. +/// +/// A `FromRanking` (bracket) round is included like any other — its ranking is its standings +/// contribution, so a class's brackets feed the season totals alongside its qual rounds. Returns an +/// error if any of the class's rounds is unscorable (an unknown format, a dangling seeding source). +pub fn class_standings( + meta: &EventMeta, + class: &ClassId, + events: &[Event], +) -> Result { + let mut acc: BTreeMap = BTreeMap::new(); + + for round in rounds_for_class(meta, class) { + // The round's scored heats — the same view `round_ranking` ranked over — so the laps a + // standing reports come from exactly the heats that decided the round position. + let completed = completed_heats(round, events); + // An UNRACED round contributes nothing (user-approved policy): with zero completed + // heats its "ranking" seeds the whole field tied at 1, so folding it handed every + // member `field_size` free points and a phantom rounds_entered — defining the finals + // rounds up front visibly shifted the standings after qualifying alone (and unevenly, + // for rounds whose seeded field differs). + if completed.is_empty() { + continue; + } + let ranking = round_ranking(meta, round, events)?; + let field_size = ranking.len() as u32; + // Best (fastest) lap per competitor, folded from the *same* finalized heats via the lap-list + // projection — independent of the win condition, so a Timed / FirstToLaps race reports a + // best lap from its real per-lap durations rather than the null its placement metric carries. + let best_laps = round_best_laps(round, events); + + for entry in &ranking { + // Points: a win (position 1) is worth the field size; last is worth 1. + let points = field_size.saturating_sub(entry.position).saturating_add(1); + // Laps for this competitor across the round's heats. + let laps = completed + .iter() + .map(|heat| placement_laps(&heat.result, &entry.competitor)) + .sum(); + let best_lap = best_laps.get(&entry.competitor).copied(); + acc.entry(entry.competitor.clone()) + .or_default() + .add_round(points, laps, best_lap); + } + } + + // Apply the marshaling **standings points adjustments** (Slice 6): a `PointsDeducted` / + // `PointsAdded` penalty shifts a competitor's *season/event* points without touching their + // per-heat lap result. Scoped to this class's heats so a deduction in one class never leaks + // into another class the pilot also races. A deduction only applies to a competitor who + // actually accrued round points (is in `acc`); a points award/deduction for a non-entrant is a + // no-op here (they have no standings row to adjust). The total saturates at zero. + let class_heats = class_heat_ids(meta, class, events); + let adjustments = points_adjustments(events, &class_heats); + for (competitor, delta) in adjustments { + if let Some(a) = acc.get_mut(&competitor) { + a.points = (i64::from(a.points) + delta).max(0) as u32; + } + } + + // Order the rows: most points first, then faster best lap (no-lap last), then competitor ref + // (the BTreeMap already yields ref order, the stable final tie-break). + let mut rows: Vec<(CompetitorRef, StandingAcc)> = acc.into_iter().collect(); + rows.sort_by(|(a_ref, a), (b_ref, b)| { + b.points + .cmp(&a.points) + .then_with(|| best_lap_order(a.best_lap_micros).cmp(&best_lap_order(b.best_lap_micros))) + .then_with(|| a_ref.0.cmp(&b_ref.0)) + }); + + // Assign dense, tie-aware positions: equal points *and* best lap share a position, the next + // distinct row skips past them (1, 2, 2, 4). + let mut standings = Vec::with_capacity(rows.len()); + let mut position = 0u32; + let mut prev_key: Option<(u32, Option)> = None; + for (index, (competitor, a)) in rows.into_iter().enumerate() { + let key = (a.points, a.best_lap_micros); + if prev_key != Some(key) { + position = index as u32 + 1; + prev_key = Some(key); + } + standings.push(ClassStanding { + competitor, + position, + points: a.points, + best_lap_micros: a.best_lap_micros, + total_laps: a.total_laps, + rounds_entered: a.rounds_entered, + }); + } + + Ok(ClassStandings { + class: class.clone(), + standings, + }) +} + +/// A sort key that orders a best lap **faster-first**, with "no lap" ranked last: `Some(µs)` keeps +/// its value, `None` becomes `i64::MAX` so a competitor who never completed a lap sinks below every +/// competitor who did. +fn best_lap_order(best: Option) -> i64 { + best.unwrap_or(i64::MAX) +} + +/// Fill a round (race redesign Slice 3a): build its generator from the field + the round's +/// completed heats off the log, and decide the next heat to schedule. +/// +/// Pure with respect to the log — it reads but never appends; appending the tagged +/// `HeatScheduled` is the control handler's job. Deterministic given the same `events` + +/// `meta`, exactly like [`Generator::next`](gridfpv_engine::format::Generator::next). +pub fn fill_round( + meta: &EventMeta, + timers: &TimerRegistry, + round_id: &RoundId, + events: &[Event], +) -> Result { + let round = round_of(meta, round_id)?; + + // Mode-aware heat formation (race redesign Slice 7a). A **static** round (time-trial / qual) + // forms channel-balanced heats off each member's fixed channel; a **per-heat** round (brackets) + // runs the format generator's heats and lets the handler first-fit channels (the prior path). + match round.channel_mode { + ChannelMode::Static => fill_round_static(meta, timers, round, events), + ChannelMode::PerHeat => fill_round_per_heat(meta, round, round_id, events), + } +} + +/// Fill a **per-heat** (bracket) round (race redesign Slice 7a / the original Slice 3a path): run +/// the format generator and emit the next not-yet-scheduled heat, channels assigned later by the +/// handler's first-fit. Unchanged behaviour — only extracted from [`fill_round`]. +fn fill_round_per_heat( + meta: &EventMeta, + round: &RoundDef, + round_id: &RoundId, + events: &[Event], +) -> Result { + let field = round_field(meta, round, events)?; + if field.is_empty() { + return Err(FillError::EmptyField(round_id.0.clone())); + } + // FREEZE-AT-FILL (#334): a carry seeding records its resolved draw alongside the round's + // FIRST scheduled heat (the handler appends `RoundFieldDrawn` before the `HeatScheduled`). + // From then on `round_field` replays the recorded draw — see `round_field_at`. + let field_draw = (seeding_freezes(&round.seeding) + && recorded_field(events, round_id).is_none()) + .then(|| field.clone()); + + let registry = FormatRegistry::standard(); + let mut generator = registry + .build(&round.format, &format_config(round, field)) + .ok_or_else(|| FillError::UnknownFormat(round.format.clone()))?; + + let completed = completed_heats(round, events); + if completed.len() >= MAX_HEATS_PER_ROUND { + return Ok(FillOutcome::Complete); + } + + match generator.next(&completed) { + GeneratorStep::Run(plans) => { + // The interactive flow schedules **one** heat per FillRound (the RD drives each + // heat to Finalize before asking for the next). A generator that emits several + // plans at once (a bracket round) still advances one heat at a time: take the + // first not-yet-scheduled plan. Dedup against already-tagged heats so a repeated + // FillRound before the prior heat is scored does not double-schedule it. + // Scope every generator heat id to the round. A generator's ids are only unique + // WITHIN its own round (`h2h-h0`, `tq-r1-h0`, …): two rounds of the same format in + // one event would log colliding `HeatScheduled` ids, and every by-id fold (heat + // state, windows, live control) would then conflate two different heats — corrupted + // results. Scoping with the round id (`{round}-{generator-id}`) makes ids globally + // unique while staying deterministic; `unscope_heat_id` strips the prefix when + // history is handed back to the generator. (Open practice keeps its dedicated + // `{round}-heat` form, issue #54.) Rewritten **before** the dedup so + // `already`/`scheduled_round_heats` (which read the scoped id from the log) match + // it and the fill stays idempotent per round. + let open_practice = is_open_practice(round); + // Keep each plan's RAW generator id alongside the scoped rewrite: a round filled + // before scoping existed logged the raw ids, so the dedup below must recognize a + // plan as already-scheduled under EITHER form or an upgrade mid-round would + // double-schedule its remaining heats. + let plans: Vec<_> = plans + .into_iter() + .map(|mut plan| { + let raw = plan.heat.clone(); + plan.heat = if open_practice { + open_practice_heat_id(round_id) + } else { + scoped_heat_id(round_id, &plan.heat) + }; + (raw, plan) + }) + .collect(); + let already: Vec = scheduled_round_heats(events, round_id); + let next = plans + .into_iter() + .find(|(raw, p)| !already.contains(&p.heat) && !already.contains(raw)) + .map(|(_, p)| p); + // Open practice (open-practice format): the heat carries **empty** frequencies — its + // lineup is the active *channels* themselves (`node-{i}` seats), so there is nothing to + // allocate. Force `Some(empty)` so the handler appends the logged `HeatScheduled` with no + // frequencies regardless of the timer's channel pool. + let open_practice_frequencies = open_practice.then(Vec::new); + match next { + Some(plan) => Ok(FillOutcome::Scheduled { + heat: plan.heat, + lineup: plan.lineup, + // Per-heat: the handler assigns channels from the timer pool (first-fit), except + // for open practice which carries empty frequencies (the lineup is channels). + frequencies: open_practice_frequencies, + field_draw, + }), + // Every plan the generator wants this step is already scheduled (the RD + // re-issued FillRound before scoring the outstanding heat): nothing new to + // append. Report [`AlreadyScheduled`] — a typed ok the handler answers + // without appending, distinct from a finished round. + None => Ok(FillOutcome::AlreadyScheduled), + } + } + GeneratorStep::Complete => Ok(FillOutcome::Complete), + } +} + +/// Fill a **static** (time-trial / qual) round with **channel-balanced** heats (race redesign Slice +/// 7a). +/// +/// Static rounds give each member a *fixed* channel at membership; this builds the round's full, +/// deterministic plan of channel-balanced heats — each heat draws pilots on **distinct channels**, +/// **≤ `node_count` pilots** (the node cap is the only per-heat size limit; the channel pool may be +/// larger) — then emits the next not-yet-scheduled one (one per FillRound), or +/// [`Complete`](FillOutcome::Complete) once every planned heat is scheduled. Each emitted heat +/// carries its pilots' assigned channels as `frequencies` (no first-fit). +/// +/// A member with no assigned channel is a [`FillError::MissingChannel`]. An empty field is a +/// [`FillError::EmptyField`], as for per-heat. +fn fill_round_static( + meta: &EventMeta, + timers: &TimerRegistry, + round: &RoundDef, + events: &[Event], +) -> Result { + // Gather the round's members + their fixed channels (de-duplicated across eligible classes, + // first occurrence wins — a member in two eligible classes flies once on their channel). + let members = static_members(meta, round)?; + if members.is_empty() { + return Err(FillError::EmptyField(round.id.0.clone())); + } + + // The node cap is the event's primary timer's node count (the only per-heat size limit); with + // no resolvable timer, fall back to seating every distinct channel in one heat (a pure-sim + // event still channel-balances by the distinct-channel rule, just without a node cap). + let node_cap = assignment_timer(meta, timers) + .map(|t| t.node_count as usize) + .filter(|n| *n > 0) + .unwrap_or(usize::MAX); + + // How many times the whole field flies — the format's round count (e.g. `timed_qual` runs + // `rounds` rounds). Channel-balanced heats are built per format-round so every member flies + // each round, across the configured round count. `0` = open-ended (generate the next heat on + // demand forever; see `static_round_count`). + let format_rounds = static_round_count(round); + let already: Vec = scheduled_round_heats(events, &round.id); + + // For the open-ended case, plan just enough of the (infinite) channel-balanced rotation to yield + // one not-yet-scheduled heat: one extra format-round beyond what's already been generated. The + // rotation + ids are deterministic, so this always surfaces the next heat and never completes — + // the RD ends the round by simply not asking for more. + let rounds_to_plan = if format_rounds == 0 { + let per_round = channel_balanced_plan(round, &members, node_cap, 1) + .len() + .max(1); + already.len() / per_round + 1 + } else { + format_rounds + }; + + let plans = channel_balanced_plan(round, &members, node_cap, rounds_to_plan); + + // One heat per FillRound: emit the first plan not already scheduled (dedup like per-heat). + let next = plans.into_iter().find(|(heat, _)| !already.contains(heat)); + match next { + Some((heat, assignment)) => { + let lineup = assignment.iter().map(|(c, _)| c.clone()).collect(); + Ok(FillOutcome::Scheduled { + heat, + lineup, + frequencies: Some(assignment), + // Static rounds are validated FromRoster-only, and roster seedings never + // freeze (late entrants stay welcome) — no draw to record. + field_draw: None, + }) + } + // Fixed-count: every planned channel-balanced heat is already scheduled → the round is + // complete. (Open-ended always finds a fresh heat above, so it never lands here.) + None => Ok(FillOutcome::Complete), + } +} + +/// The round's members as `(competitor, channel)` for **static** formation (race redesign Slice 7a): +/// each member's fixed channel, de-duplicated across the round's eligible classes (first occurrence +/// wins). A member with no assigned channel is a [`FillError::MissingChannel`]. +fn static_members( + meta: &EventMeta, + round: &RoundDef, +) -> Result, FillError> { + let mut out: Vec<(CompetitorRef, u16)> = Vec::new(); + for class in &round.classes { + let Some(membership) = meta.classes_membership.iter().find(|m| &m.class == class) else { + continue; + }; + for slot in &membership.pilots { + let competitor = CompetitorRef(slot.pilot.0.clone()); + if out.iter().any(|(c, _)| c == &competitor) { + continue; + } + let channel = slot + .channel + .ok_or_else(|| FillError::MissingChannel(slot.pilot.0.clone()))?; + out.push((competitor, channel)); + } + } + Ok(out) +} + +/// Build the full, deterministic **channel-balanced** heat plan for a static round (race redesign +/// Slice 7a): for each of `format_rounds` rounds, partition the members into heats where every heat +/// has **distinct channels** and **≤ `node_cap` pilots**. +/// +/// The builder groups members by channel (preserving membership order within a channel), then draws +/// one pilot per distinct channel into each heat, capped at `node_cap`, until a round's members are +/// exhausted — so two pilots sharing a channel land in *different* heats and every heat is +/// channel-distinct. Heat ids are `"-r-h"`, stable across replays. +fn channel_balanced_plan( + round: &RoundDef, + members: &[(CompetitorRef, u16)], + node_cap: usize, + format_rounds: usize, +) -> Vec<(HeatId, Vec<(CompetitorRef, u16)>)> { + // Group members by channel, preserving order. `groups` is the distinct channels in first-seen + // order; each holds that channel's pilots as a FIFO queue to draw from. + let mut channels: Vec = Vec::new(); + let mut groups: BTreeMap> = BTreeMap::new(); + for (competitor, channel) in members { + if !channels.contains(channel) { + channels.push(*channel); + } + groups.entry(*channel).or_default().push(competitor.clone()); + } + + let mut plans: Vec<(HeatId, Vec<(CompetitorRef, u16)>)> = Vec::new(); + for r in 0..format_rounds.max(1) { + // Per format-round, redraw the same channel queues so every member flies once this round. + let mut queues: BTreeMap> = groups + .iter() + .map(|(ch, pilots)| (*ch, pilots.iter().cloned().collect())) + .collect(); + let mut heat_index = 0usize; + loop { + // One heat: draw the next available pilot from each distinct channel, in channel order, + // up to the node cap. Channels with an empty queue are skipped. + let mut heat: Vec<(CompetitorRef, u16)> = Vec::new(); + for channel in &channels { + if heat.len() >= node_cap { + break; + } + if let Some(queue) = queues.get_mut(channel) { + if let Some(pilot) = queue.pop_front() { + heat.push((pilot, *channel)); + } + } + } + if heat.is_empty() { + break; // this format-round's members are exhausted + } + let heat_id = HeatId(format!( + "{}-r{}-h{}", + slugify(&round.id.0), + r + 1, + heat_index + 1 + )); + plans.push((heat_id, heat)); + heat_index += 1; + } + } + plans +} + +/// The number of times a static round's field flies (its format round count, race redesign Slice +/// 7a): the `rounds` param, defaulting to the qualifying formats' defaults (`timed_qual` 3, +/// `round_robin` 3), else 1. Read off the round's params verbatim. +/// +/// **`0` means open-ended** (the RD set "Heats per pilot" to 0): instead of a fixed number of +/// rounds, the round generates the next heat on demand each fill, indefinitely, until the RD stops +/// — see [`fill_round_static`]. An absent param falls back to the format default (never 0), so +/// open-ended is only ever an explicit choice. +fn static_round_count(round: &RoundDef) -> usize { + let default = match round.format.as_str() { + "timed_qual" | "round_robin" => 3, + _ => 1, + }; + let rounds: usize = round + .params + .get("rounds") + .and_then(|v| v.parse().ok()) + .unwrap_or(default); + // 0 stays the explicit open-ended sentinel; a positive count clamps so the materialized + // full plan is bounded (an unchecked raw-API `rounds=999999999` would otherwise allocate + // unbounded memory at fill). Mirrors the generator-side clamp. + if rounds == 0 { + 0 + } else { + rounds.min(gridfpv_engine::timed_qual::TimedQualifying::MAX_ROUNDS) + } +} + +/// Slugify a round id into a heat-id-safe stem (lowercase alnum, other runs → single `-`). +fn slugify(id: &str) -> String { + let mut out = String::new(); + let mut prev_dash = false; + for ch in id.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + prev_dash = false; + } else if !prev_dash { + out.push('-'); + prev_dash = true; + } + } + let trimmed = out.trim_matches('-'); + if trimmed.is_empty() { + "round".to_string() + } else { + trimmed.to_string() + } +} + +/// The heat ids scheduled (tagged) for a round so far, in first-scheduled order. +fn scheduled_round_heats(events: &[Event], round_id: &RoundId) -> Vec { + let mut out: Vec = Vec::new(); + for event in events { + if let Event::HeatScheduled { + heat, + round: Some(r), + .. + } = event + { + if r == round_id && !out.contains(heat) { + out.push(heat.clone()); + } + } + } + out +} + +/// The single-class tag for a round's scheduled heats, if any — re-exported for the handler +/// so it tags the `HeatScheduled` consistently with [`completed_heats`]'s round filter. +pub fn round_class(meta: &EventMeta, round_id: &RoundId) -> Option { + meta.rounds + .iter() + .find(|r| &r.id == round_id) + .and_then(single_class) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::StartProcedure; + use crate::events::{ + ChannelMode, ClassMembership, EventMeta, MemberSlot, RoundDef, SeedingRule, + default_grace_window, default_staging_timer_secs, + }; + use crate::scope::{ClassId as ScopeClassId, EventId, PilotId}; + use gridfpv_engine::scoring::WinCondition; + use gridfpv_events::{AdapterId, GateIndex, HeatTransition, LogRef, Pass, Penalty, SourceTime}; + use std::collections::BTreeMap; + + const ADAPTER: &str = "mock"; + + // --- Channel assignment (race redesign Slice 4a) --------------------------------------- + + use crate::channels::RACEBAND_MHZ; + use crate::timers::{ChannelCapability, Timer, TimerId, TimerKind, TimerStatus}; + + /// A test timer with the given node count + available channels (raw MHz), flexible capability. + fn timer_with(node_count: u32, available: Vec) -> Timer { + Timer { + id: TimerId("t".into()), + name: "T".into(), + kind: TimerKind::Mock { laps: 1, lap_ms: 1 }, + status: TimerStatus::Ready, + channel_capability: ChannelCapability::Flexible, + node_count, + available_channels: available, + plugin: None, + } + } + + fn lineup(names: &[&str]) -> Vec { + names.iter().map(|n| CompetitorRef((*n).into())).collect() + } + + #[test] + fn assign_picks_the_imd_best_subset_in_seed_order() { + // #209 auto-pick: an 8-node Raceband timer no longer first-fits R1,R2,R3 (which has a + // third-order product landing exactly on R3 — IMD score 0). It selects the IMD-cleanest + // 3-channel subset — [5658, 5732, 5917] (score 74) — and lays it onto the seeds in order + // (lowest channel → top seed). + let timer = timer_with(8, RACEBAND_MHZ.to_vec()); + let assignment = assign_frequencies(&timer, &lineup(&["A", "B", "C"])).unwrap(); + assert_eq!( + assignment, + vec![ + (CompetitorRef("A".into()), 5658), + (CompetitorRef("B".into()), 5732), + (CompetitorRef("C".into()), 5917), + ] + ); + // The chosen set is strictly cleaner than the naive first-fit R1,R2,R3. + let chosen: Vec = assignment.iter().map(|(_, f)| *f).collect(); + assert!( + gridfpv_engine::imd::imd_score(&chosen) + > gridfpv_engine::imd::imd_score(&[5658, 5695, 5732]), + "IMD-best subset must beat first-fit" + ); + } + + #[test] + fn assign_is_deterministic() { + let timer = timer_with(8, RACEBAND_MHZ.to_vec()); + let l = lineup(&["X", "Y", "Z", "W"]); + assert_eq!( + assign_frequencies(&timer, &l).unwrap(), + assign_frequencies(&timer, &l).unwrap() + ); + } + + #[test] + fn assign_with_no_available_channels_is_empty() { + // A sim/Mock-without-frequencies (no available channels) assigns no channels — but the cap + // still applies (covered separately). + let timer = timer_with(8, vec![]); + let assignment = assign_frequencies(&timer, &lineup(&["A", "B"])).unwrap(); + assert!(assignment.is_empty()); + } + + #[test] + fn assign_rejects_an_oversized_lineup_at_the_node_cap() { + // A 4-node timer cannot seat a 5-pilot heat — TooManyForNodes regardless of channel count. + let timer = timer_with(4, RACEBAND_MHZ.to_vec()); + let err = assign_frequencies(&timer, &lineup(&["A", "B", "C", "D", "E"])).unwrap_err(); + assert_eq!( + err, + AssignError::TooManyForNodes { + lineup: 5, + nodes: 4 + } + ); + } + + #[test] + fn assign_caps_the_pool_to_the_node_count_even_with_more_channels() { + // 2 nodes but 8 available channels: a 3-pilot lineup is rejected at the node cap first. + let timer = timer_with(2, RACEBAND_MHZ.to_vec()); + let err = assign_frequencies(&timer, &lineup(&["A", "B", "C"])).unwrap_err(); + assert_eq!( + err, + AssignError::TooManyForNodes { + lineup: 3, + nodes: 2 + } + ); + // A 2-pilot lineup fits and gets the first two channels. + let ok = assign_frequencies(&timer, &lineup(&["A", "B"])).unwrap(); + assert_eq!(ok.len(), 2); + } + + #[test] + fn assign_too_few_channels_within_the_node_count() { + // 8 nodes but only 2 available channels: a 3-pilot heat fits the node cap but runs out of + // distinct channels. + let timer = timer_with(8, vec![5658, 5695]); + let err = assign_frequencies(&timer, &lineup(&["A", "B", "C"])).unwrap_err(); + assert_eq!( + err, + AssignError::TooFewChannels { + lineup: 3, + available: 2 + } + ); + } + + #[test] + fn assign_imd_pick_is_capped_by_node_count_and_replay_deterministic() { + // #209 auto-pick, capped by nodes + deterministic. A 4-node Raceband timer caps the candidate + // pool to its first 4 channels (R1..R4); the IMD-best 3-subset of *that* capped pool is + // [5658, 5695, 5769] (score 37) — chosen over the first-fit R1,R2,R3 (score 0). The node cap + // bounds the candidate set, exactly as the prior first-fit did. + let timer = timer_with(4, RACEBAND_MHZ.to_vec()); + let l = lineup(&["A", "B", "C"]); + + let first = assign_frequencies(&timer, &l).unwrap(); + let chosen: Vec = first.iter().map(|(_, f)| *f).collect(); + assert_eq!( + chosen, + vec![5658, 5695, 5769], + "IMD-best of the node-capped pool" + ); + assert!( + gridfpv_engine::imd::imd_score(&chosen) + > gridfpv_engine::imd::imd_score(&[5658, 5695, 5732]), + "still beats the first-fit even within the node cap" + ); + + // Fold/fill twice → identical (no clock, no RNG): the assignment replays deterministically. + let second = assign_frequencies(&timer, &l).unwrap(); + assert_eq!(first, second, "IMD assignment is replay-deterministic"); + } + + /// A timer registry with **no resolvable primary** for the per-heat tests (the meta selects no + /// timers, so `assignment_timer` resolves `None` and static formation falls back to no node cap). + fn no_timers() -> TimerRegistry { + TimerRegistry::new(None, 1, 1).unwrap() + } + + /// An event meta selecting a primary timer with `node_count` nodes and a Raceband channel pool — + /// for the static channel-balanced formation tests (race redesign Slice 7a). + fn meta_with_timer( + rounds: Vec, + membership: Vec, + node_count: u32, + ) -> (EventMeta, TimerRegistry) { + let timers = TimerRegistry::new(None, 1, 1).unwrap(); + let timer = timers + .update( + &TimerId(crate::timers::MOCK_TIMER_ID.into()), + &crate::timers::UpdateTimerRequest { + node_count: Some(node_count), + available_channels: Some(crate::channels::RACEBAND_MHZ.to_vec()), + ..Default::default() + }, + ) + .unwrap(); + let mut meta = meta_with(rounds, membership); + meta.timers = vec![timer.id.clone()]; + meta.primary_timer = Some(timer.id); + (meta, timers) + } + + fn meta_with(rounds: Vec, membership: Vec) -> EventMeta { + EventMeta { + id: EventId("e".into()), + name: "E".into(), + created_at: 0, + persistent: false, + date: None, + location: None, + description: None, + organizer: None, + timers: vec![], + primary_timer: None, + roster: vec![], + classes: vec![ScopeClassId("open".into())], + classes_membership: membership, + rounds, + } + } + + fn member(class: &str, pilots: &[&str]) -> ClassMembership { + ClassMembership { + class: ScopeClassId(class.into()), + pilots: pilots + .iter() + .map(|p| MemberSlot::new(PilotId((*p).into()))) + .collect(), + } + } + + /// A class membership where each pilot carries a fixed channel: `(pilot, channel)` pairs — for + /// the static channel-balanced formation tests (race redesign Slice 7a). + fn member_chan(class: &str, pilots: &[(&str, u16)]) -> ClassMembership { + ClassMembership { + class: ScopeClassId(class.into()), + pilots: pilots + .iter() + .map(|(p, ch)| MemberSlot { + pilot: PilotId((*p).into()), + channel: Some(*ch), + }) + .collect(), + } + } + + /// The existing per-heat (bracket-path) qual fixture — explicitly `PerHeat` so the whole-field + /// single-heat behaviour the Slice-3 tests assert is preserved. + fn qual_round(id: &str, class: &str) -> RoundDef { + RoundDef { + id: RoundId(id.into()), + label: id.into(), + classes: vec![ScopeClassId(class.into())], + format: "timed_qual".into(), + params: BTreeMap::from([("rounds".into(), "1".into())]), + win_condition: WinCondition::BestLap, + seeding: SeedingRule::FromRoster, + channel_mode: ChannelMode::PerHeat, + staging_timer_secs: default_staging_timer_secs(), + start_procedure: StartProcedure::default(), + grace_window: default_grace_window(), + protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, + time_limit_secs: None, + } + } + + fn scheduled(heat: &str, round: &str, class: &str, lineup: &[&str]) -> Event { + Event::HeatScheduled { + heat: HeatId(heat.into()), + lineup: lineup.iter().map(|c| CompetitorRef((*c).into())).collect(), + class: Some(ClassId(class.into())), + round: Some(RoundId(round.into())), + frequencies: vec![], + label: None, + } + } + + fn changed(heat: &str, t: HeatTransition) -> Event { + Event::HeatStateChanged { + heat: HeatId(heat.into()), + transition: t, + } + } + + fn pass(c: &str, at: i64, seq: u64) -> Event { + Event::Pass(Pass { + adapter: AdapterId(ADAPTER.into()), + competitor: CompetitorRef(c.into()), + at: SourceTime::from_micros(at), + sequence: Some(seq), + gate: GateIndex::LAP, + signal: None, + heat: None, + }) + } + + /// Drive one heat from Running to Final with a set of best-lap passes, returning the + /// events that span it (schedule is the caller's). + fn run_heat_events(heat: &str, passes: Vec) -> Vec { + let mut v = vec![ + changed(heat, HeatTransition::Staged), + changed(heat, HeatTransition::Armed), + changed(heat, HeatTransition::Running), + ]; + v.extend(passes); + v.push(changed(heat, HeatTransition::Finished)); + v.push(changed(heat, HeatTransition::Finalized)); + v + } + + #[test] + fn fill_round_builds_field_from_membership_and_emits_tagged_heat() { + let round = qual_round("q1", "open"); + let meta = meta_with(vec![round], vec![member("open", &["A", "B", "C"])]); + // Nothing run yet → the generator emits round 1 over the whole class membership. + let outcome = fill_round(&meta, &no_timers(), &RoundId("q1".into()), &[]).unwrap(); + match outcome { + FillOutcome::Scheduled { lineup, .. } => { + assert_eq!( + lineup, + vec![ + CompetitorRef("A".into()), + CompetitorRef("B".into()), + CompetitorRef("C".into()) + ] + ); + } + other => panic!("expected a scheduled heat, got {other:?}"), + } + } + + #[test] + fn fill_round_empty_membership_is_an_error() { + let round = qual_round("q1", "open"); + let meta = meta_with(vec![round], vec![]); + assert!(matches!( + fill_round(&meta, &no_timers(), &RoundId("q1".into()), &[]), + Err(FillError::EmptyField(_)) + )); + } + + #[test] + fn open_ended_static_round_keeps_generating_the_next_heat() { + // "Heats per pilot = 0" on a static (time-trial) round: each fill yields the next heat in + // the continuing channel-balanced rotation, and it never completes. + let mut round = qual_round("tt", "open"); + round.channel_mode = ChannelMode::Static; + round.params.insert("rounds".into(), "0".into()); + // 3 pilots on distinct channels, a 2-node timer -> 2 heats per format-round (A&B, then C). + let (meta, timers) = meta_with_timer( + vec![round], + vec![member_chan( + "open", + &[("A", 5658), ("B", 5695), ("C", 5760)], + )], + 2, + ); + let rid = RoundId("tt".into()); + + let mut events: Vec = Vec::new(); + let mut ids: Vec = Vec::new(); + for _ in 0..5 { + match fill_round(&meta, &timers, &rid, &events).unwrap() { + FillOutcome::Scheduled { heat, lineup, .. } => { + let names: Vec<&str> = lineup.iter().map(|c| c.0.as_str()).collect(); + events.push(scheduled(&heat.0, "tt", "open", &names)); + ids.push(heat.0); + } + other => { + panic!("open-ended round must always schedule another heat, got {other:?}") + } + } + } + let unique: std::collections::HashSet<_> = ids.iter().cloned().collect(); + assert_eq!(unique.len(), 5, "every generated heat is distinct: {ids:?}"); + assert!(ids[0].ends_with("-r1-h1"), "first heat is r1-h1: {ids:?}"); + assert!( + ids.iter().any(|i| i.ends_with("-r2-h1")), + "the rotation continues into round 2: {ids:?}" + ); + } + + // --- Qualifying metric is derived from the win condition (Rounds form redesign) -------------- + + #[test] + fn qual_metric_for_timed_qual_maps_each_win_condition() { + // The qualifying metric IS the win condition: each maps to the matching QualMetric string, + // and First-to-N (not a qualifying metric) falls to the best-lap default. + assert_eq!( + qual_metric_for("timed_qual", WinCondition::BestLap), + Some("best-lap") + ); + assert_eq!( + qual_metric_for("timed_qual", WinCondition::BestConsecutive { n: 3 }), + Some("best-consecutive") + ); + assert_eq!( + qual_metric_for( + "timed_qual", + WinCondition::Timed { + window_micros: 120_000_000 + } + ), + Some("most-laps") + ); + assert_eq!( + qual_metric_for("timed_qual", WinCondition::FirstToLaps { n: 5 }), + Some("best-lap") + ); + } + + #[test] + fn qual_metric_for_round_robin_maps_timed_to_total_laps_else_points() { + // round_robin: Timed (most laps) → total-laps; everything else → the points standing. + assert_eq!( + qual_metric_for( + "round_robin", + WinCondition::Timed { + window_micros: 60_000_000 + } + ), + Some("total-laps") + ); + assert_eq!( + qual_metric_for("round_robin", WinCondition::BestLap), + Some("points") + ); + assert_eq!( + qual_metric_for("round_robin", WinCondition::BestConsecutive { n: 3 }), + Some("points") + ); + assert_eq!( + qual_metric_for("round_robin", WinCondition::FirstToLaps { n: 4 }), + Some("points") + ); + } + + #[test] + fn qual_metric_for_non_qualifying_format_is_none() { + // A bracket format keeps its params verbatim — no derived metric. + assert_eq!(qual_metric_for("single_elim", WinCondition::BestLap), None); + assert_eq!( + qual_metric_for("open_practice", WinCondition::BestLap), + None + ); + } + + #[test] + fn format_config_injects_the_win_condition_derived_metric() { + // The built FormatConfig carries the metric derived from the win condition (the single + // source of truth), overriding any stale stored `metric` param. + let mut round = qual_round("q1", "open"); + round.win_condition = WinCondition::Timed { + window_micros: 90_000_000, + }; + // A stale stored metric must be overridden by the win-condition-derived one. + round.params.insert("metric".into(), "best-lap".into()); + let config = format_config(&round, vec![CompetitorRef("A".into())]); + assert_eq!( + config.params.get("metric").map(String::as_str), + Some("most-laps") + ); + } + + #[test] + fn round_ranking_ranks_by_the_win_condition_derived_metric() { + // A timed_qual round scored under Timed (Most Laps): the ranking must rank by most-laps + // (the win-condition-derived metric), NOT the best-lap default — even with no stored metric. + let mut round = qual_round("q1", "open"); + round.win_condition = WinCondition::Timed { + window_micros: 100_000_000, + }; + let meta = meta_with(vec![round.clone()], vec![member("open", &["A", "B"])]); + + // One heat over A,B where B completes more laps inside the window than A. + // Passes (lap-gate): A holeshot then 2 more laps (2 counted); B holeshot then 3 more (3). + let mut events = vec![scheduled("q1-h", "q1", "open", &["A", "B"])]; + let passes = vec![ + pass("A", 0, 0), + pass("B", 0, 1), + pass("A", 10_000_000, 2), + pass("B", 10_000_000, 3), + pass("A", 20_000_000, 4), + pass("B", 20_000_000, 5), + pass("B", 30_000_000, 6), + ]; + events.extend(run_heat_events("q1-h", passes)); + + let ranking = round_ranking(&meta, &round, &events).unwrap(); + // B banked more laps → ranks ahead of A under the most-laps qualifying metric. + assert_eq!(ranking[0].competitor, CompetitorRef("B".into())); + assert_eq!(ranking[0].position, 1); + } + + // --- Per-round standings (time-trial / qual display) ---------------------------------------- + + /// A scored heat over `pilots`, each flying the given absolute lap-gate pass times (µs), run to + /// Final and tagged with the round + class. The first time is the holeshot; each later time + /// completes a lap. + fn scored_heat(heat: &str, round: &str, class: &str, pilots: &[(&str, &[i64])]) -> Vec { + let names: Vec<&str> = pilots.iter().map(|(n, _)| *n).collect(); + let mut log = vec![scheduled(heat, round, class, &names)]; + let mut passes = Vec::new(); + let mut seq = 0u64; + for (name, times) in pilots { + for &t in *times { + passes.push(pass(name, t, seq)); + seq += 1; + } + } + log.extend(run_heat_events(heat, passes)); + log + } + + /// The `(competitor, position)` pairs of a standings list — to assert parity with the ranking. + fn positions(standings: &[RoundStanding]) -> Vec<(CompetitorRef, u32)> { + standings + .iter() + .map(|s| (s.competitor.clone(), s.position)) + .collect() + } + + /// Look up a standing by competitor name. + fn standing<'a>(standings: &'a [RoundStanding], name: &str) -> &'a RoundStanding { + standings + .iter() + .find(|s| s.competitor.0 == name) + .unwrap_or_else(|| panic!("no standing for {name}")) + } + + #[test] + fn round_standings_best_consecutive_populates_metric_and_best_lap() { + // A timed_qual round scored under BestConsecutive{3}: each pilot's best lap is always + // computed, the metric carries the smallest 3-consec window, and positions match the ranking. + let mut round = qual_round("q1", "open"); + round.win_condition = WinCondition::BestConsecutive { n: 3 }; + let meta = meta_with(vec![round.clone()], vec![member("open", &["A", "B"])]); + // A: four 1.0s laps → best-3-consec 3.0s, best lap 1.0s. + // B: four 1.5s laps → best-3-consec 4.5s, best lap 1.5s. + let log = scored_heat( + "q-1", + "q1", + "open", + &[ + ("A", &[0, 1_000_000, 2_000_000, 3_000_000, 4_000_000]), + ("B", &[0, 1_500_000, 3_000_000, 4_500_000, 6_000_000]), + ], + ); + + let standings = round_standings(&meta, &round, &log).unwrap(); + // Positions are byte-for-byte the ranking's. + let ranking = round_ranking(&meta, &round, &log).unwrap(); + assert_eq!( + positions(&standings), + ranking + .iter() + .map(|e| (e.competitor.clone(), e.position)) + .collect::>() + ); + + let a = standing(&standings, "A"); + assert_eq!(a.position, 1); + assert_eq!(a.best_lap_micros, Some(1_000_000)); + assert_eq!( + a.metric, + RoundMetric::BestConsecutive { + n: 3, + micros: Some(3_000_000) + } + ); + let b = standing(&standings, "B"); + assert_eq!(b.position, 2); + assert_eq!(b.best_lap_micros, Some(1_500_000)); + assert_eq!( + b.metric, + RoundMetric::BestConsecutive { + n: 3, + micros: Some(4_500_000) + } + ); + } + + #[test] + fn round_standings_timed_reports_most_laps_metric() { + // A Timed round: the metric is MostLaps with the per-pilot lap counts, best lap still set. + let mut round = qual_round("q1", "open"); + round.win_condition = WinCondition::Timed { + window_micros: 100_000_000, + }; + let meta = meta_with(vec![round.clone()], vec![member("open", &["A", "B"])]); + // Within the 100s window: B completes 3 laps, A completes 2. + let log = scored_heat( + "q-1", + "q1", + "open", + &[ + ("A", &[0, 10_000_000, 20_000_000]), + ("B", &[0, 10_000_000, 20_000_000, 30_000_000]), + ], + ); + + let standings = round_standings(&meta, &round, &log).unwrap(); + let b = standing(&standings, "B"); + assert_eq!(b.position, 1); + assert_eq!(b.metric, RoundMetric::MostLaps { laps: 3 }); + assert_eq!(b.laps, 3); + // Best lap is still folded from the real lap durations, independent of the win condition. + assert_eq!(b.best_lap_micros, Some(10_000_000)); + let a = standing(&standings, "A"); + assert_eq!(a.metric, RoundMetric::MostLaps { laps: 2 }); + assert_eq!(a.laps, 2); + } + + #[test] + fn round_standings_best_lap_metric_equals_best_lap_micros() { + // A BestLap round: the metric is BestLap and equals best_lap_micros for every pilot. + let round = qual_round("q1", "open"); // win_condition defaults to BestLap + let meta = meta_with(vec![round.clone()], vec![member("open", &["A", "B"])]); + let log = scored_heat( + "q-1", + "q1", + "open", + &[ + ("A", &[0, 1_000_000, 3_000_000]), // laps 1.0s, 2.0s → best 1.0s + ("B", &[0, 1_500_000, 3_000_000]), // laps 1.5s, 1.5s → best 1.5s + ], + ); + + let standings = round_standings(&meta, &round, &log).unwrap(); + let a = standing(&standings, "A"); + assert_eq!(a.position, 1); + assert_eq!(a.best_lap_micros, Some(1_000_000)); + assert_eq!( + a.metric, + RoundMetric::BestLap { + micros: a.best_lap_micros + } + ); + let b = standing(&standings, "B"); + assert_eq!( + b.metric, + RoundMetric::BestLap { + micros: Some(1_500_000) + } + ); + } + + #[test] + fn round_standings_no_lap_pilot_ranks_last_with_null_metric() { + // Z only crosses the holeshot (no completed lap) → ranks last with a null metric + best lap. + let round = qual_round("q1", "open"); // BestLap + let meta = meta_with(vec![round.clone()], vec![member("open", &["A", "Z"])]); + let log = scored_heat("q-1", "q1", "open", &[("A", &[0, 1_000_000]), ("Z", &[0])]); + + let standings = round_standings(&meta, &round, &log).unwrap(); + let z = standing(&standings, "Z"); + assert_eq!(z.position, 2); + assert_eq!(z.best_lap_micros, None); + assert_eq!(z.laps, 0); + assert_eq!(z.metric, RoundMetric::BestLap { micros: None }); + } + + // --- #226: heat-level adjudications must reach standings / seeding / class results ------ + // + // Before the fix, `completed_heats` scored each heat over a PASS-ONLY list (every + // adjudication discarded), so the standings showed the raw on-track result while the heat + // page (app.rs `HeatProjection::Result`) showed the adjudicated one. These drive an + // adjudication through each projection and assert it MOVES the result — they fail on the old + // pass-only path and pass on the shared full-window scorer (`crate::app::score_heat_window`). + + /// A 2-pilot `head_to_head` round (Placement scoring) over `class`, scored under `win`. + /// Head-to-Head ranks by finishing position, so a DQ / time penalty that reshuffles the + /// heat's placements reshuffles the round ranking too. + fn h2h_round(id: &str, class: &str, win: WinCondition) -> RoundDef { + RoundDef { + id: RoundId(id.into()), + label: id.into(), + classes: vec![ScopeClassId(class.into())], + format: "head_to_head".into(), + params: BTreeMap::new(), + win_condition: win, + seeding: SeedingRule::FromRoster, + channel_mode: ChannelMode::PerHeat, + staging_timer_secs: default_staging_timer_secs(), + start_procedure: StartProcedure::default(), + grace_window: default_grace_window(), + protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, + time_limit_secs: None, + } + } + + /// A `PenaltyApplied` for a competitor in a heat (DQ / time penalty). + fn penalty_applied(heat: &str, competitor: &str, penalty: Penalty) -> Event { + Event::PenaltyApplied { + heat: HeatId(heat.into()), + competitor: CompetitorRef(competitor.into()), + penalty, + } + } + + /// The global append offset of a competitor's lap-gate pass at `at` µs — the `LogRef` a + /// `LapThrownOut` / `RulingReversed` targets (offsets must be PRESERVED, not re-enumerated). + fn pass_offset(log: &[Event], competitor: &str, at: i64) -> u64 { + log.iter() + .position(|e| { + matches!(e, Event::Pass(p) + if p.competitor.0 == competitor && p.at == SourceTime::from_micros(at)) + }) + .expect("pass present in log") as u64 + } + + /// The competitor refs of a ranking, best-first. + fn ranking_order(ranking: &[RankEntry]) -> Vec { + ranking.iter().map(|e| e.competitor.0.clone()).collect() + } + + #[test] + fn dq_sinks_the_pilot_in_round_ranking_standings_and_class_standings() { + // FirstToLaps head-to-head: A reaches lap 1 first (on-track winner), B second. A DQ on A + // sinks A below B in EVERY projection — not the raw on-track order. + let round = h2h_round("h2h", "open", WinCondition::FirstToLaps { n: 1 }); + let meta = meta_with(vec![round.clone()], vec![member("open", &["A", "B"])]); + let mut log = scored_heat( + "h2h-1", + "h2h", + "open", + &[("A", &[0, 1_000_000]), ("B", &[0, 2_000_000])], + ); + log.push(penalty_applied( + "h2h-1", + "A", + Penalty::Disqualify { reason: None }, + )); + + // round_ranking: B first, A last (DQ'd) — not A-first by raw reach time. + let ranking = round_ranking(&meta, &round, &log).unwrap(); + assert_eq!(ranking_order(&ranking), vec!["B", "A"]); + + // round_standings: positions mirror the ranking. + let standings = round_standings(&meta, &round, &log).unwrap(); + assert_eq!(standing(&standings, "B").position, 1); + assert_eq!(standing(&standings, "A").position, 2); + + // class_standings: B (round pos 1) outscores the DQ'd A (round pos 2). + let class = class_standings(&meta, &ClassId("open".into()), &log).unwrap(); + assert_eq!(class.standings[0].competitor.0, "B"); + assert!( + class.standings[0].points > class.standings[1].points, + "the DQ'd pilot scores fewer class points" + ); + } + + #[test] + fn lap_thrown_out_reorders_ranking_and_drops_the_pilots_best_lap() { + // timed_qual BestLap: A's fastest lap (1.0s) beats B (2.0s) on track. Throwing out that + // lap leaves A with only a 3.0s lap — so B now ranks ahead AND A's standings best-lap + // excludes the thrown-out lap. + let round = qual_round("q1", "open"); // BestLap + let meta = meta_with(vec![round.clone()], vec![member("open", &["A", "B"])]); + let mut log = scored_heat( + "q-1", + "q1", + "open", + &[("A", &[0, 1_000_000, 4_000_000]), ("B", &[0, 2_000_000])], + ); + + // Sanity: on track A leads on best lap. + assert_eq!( + ranking_order(&round_ranking(&meta, &round, &log).unwrap()), + vec!["A", "B"] + ); + + // Throw out A's 1.0s lap (the lap ENDING at the pass @1.0s). + let target = pass_offset(&log, "A", 1_000_000); + log.push(Event::LapThrownOut { + target: LogRef(target), + }); + + // Ranking flips: A's remaining lap is 3.0s, slower than B's 2.0s. + assert_eq!( + ranking_order(&round_ranking(&meta, &round, &log).unwrap()), + vec!["B", "A"] + ); + + let standings = round_standings(&meta, &round, &log).unwrap(); + assert_eq!( + standing(&standings, "A").best_lap_micros, + Some(3_000_000), + "the thrown-out 1.0s lap is excluded from A's best lap" + ); + assert_eq!(standing(&standings, "B").best_lap_micros, Some(2_000_000)); + assert_eq!(standing(&standings, "B").position, 1); + assert_eq!(standing(&standings, "A").position, 2); + } + + #[test] + fn time_added_changes_finishing_order_under_first_to_laps() { + // FirstToLaps head-to-head: A reaches lap 1 at 1.0s, B at 2.0s (A on-track winner). A + // 3.0s TimeAdded pushes A's deciding reach-time to 4.0s — behind B's 2.0s, flipping it. + let round = h2h_round("h2h", "open", WinCondition::FirstToLaps { n: 1 }); + let meta = meta_with(vec![round.clone()], vec![member("open", &["A", "B"])]); + let mut log = scored_heat( + "h2h-1", + "h2h", + "open", + &[("A", &[0, 1_000_000]), ("B", &[0, 2_000_000])], + ); + assert_eq!( + ranking_order(&round_ranking(&meta, &round, &log).unwrap()), + vec!["A", "B"] + ); + + log.push(penalty_applied( + "h2h-1", + "A", + Penalty::TimeAdded { micros: 3_000_000 }, + )); + assert_eq!( + ranking_order(&round_ranking(&meta, &round, &log).unwrap()), + vec!["B", "A"] + ); + } + + #[test] + fn heat_voided_is_excluded_from_the_projections_until_reversed() { + // The RD's "Void heat" (false start / timer glitch) must count for NOTHING downstream: + // the voided heat drops out of completed_heats — and with it round ranking, standings, + // class points, and any dependent seeding — exactly as if it had never finalized. A + // RulingReversed on the void brings it back. + let round = qual_round("q1", "open"); + let meta = meta_with(vec![round.clone()], vec![member("open", &["A", "B"])]); + let mut log = scored_heat( + "q-1", + "q1", + "open", + &[("A", &[0, 1_000_000]), ("B", &[0, 2_000_000])], + ); + let void_offset = log.len() as u64; + log.push(Event::HeatVoided { + heat: HeatId("q-1".into()), + }); + + // Voided → the heat contributes nothing: no completed heats, no ranked metrics. + assert!( + completed_heats(&round, &log).is_empty(), + "a voided heat is excluded from the round's completed heats" + ); + let standings = round_standings(&meta, &round, &log).unwrap(); + assert!( + standings.iter().all(|s| s.best_lap_micros.is_none()), + "no metric survives from a voided heat" + ); + + // Reversing the void restores the heat (and its results) in full. + log.push(Event::RulingReversed { + target: LogRef(void_offset), + }); + let restored = completed_heats(&round, &log); + assert_eq!(restored.len(), 1, "reversal brings the heat back"); + assert!(!restored[0].result.voided); + } + + #[test] + fn per_heat_result_and_round_standings_agree_on_an_adjudicated_heat() { + // The split-brain (#226) closed: the per-heat result projection (app.rs + // `HeatProjection::Result` → `score_heat_window`) and `round_standings` now score the + // SAME adjudicated window, so they agree on who the DQ sinks. + let round = h2h_round("h2h", "open", WinCondition::FirstToLaps { n: 1 }); + let meta = meta_with(vec![round.clone()], vec![member("open", &["A", "B"])]); + let mut log = scored_heat( + "h2h-1", + "h2h", + "open", + &[("A", &[0, 1_000_000]), ("B", &[0, 2_000_000])], + ); + log.push(penalty_applied( + "h2h-1", + "A", + Penalty::Disqualify { reason: None }, + )); + + // The per-heat result the heat page shows, via the exact shared helper app.rs uses. + let heat_result = + crate::app::score_heat_window(&log, &HeatId("h2h-1".into()), round.win_condition, None); + let dq_pilot = heat_result + .places + .iter() + .find(|p| p.disqualified) + .map(|p| p.competitor.competitor.0.clone()); + assert_eq!(dq_pilot.as_deref(), Some("A"), "the heat page DQs A"); + + // round_standings, going independently through completed_heats → round_ranking, ranks A + // last — the SAME pilot the heat page sinks. + let standings = round_standings(&meta, &round, &log).unwrap(); + let last = standings.iter().max_by_key(|s| s.position).unwrap(); + assert_eq!(last.competitor.0, "A"); + } + + #[test] + fn dq_heat_contributes_no_metric_or_best_lap_to_the_standings_row() { + // The #339 asymmetry closed: the ranking excludes a DQ'd placement (#331), so the + // standings row must not keep surfacing that heat's metric/best-lap next to the + // position that ignored them. A, DQ'd in their ONLY heat, ranks last AND their row + // carries no value — exactly like a no-show. B's clean row is untouched. + let round = qual_round("q1", "open"); // BestLap + let meta = meta_with(vec![round.clone()], vec![member("open", &["A", "B"])]); + let mut log = scored_heat( + "q-1", + "q1", + "open", + &[("A", &[0, 1_000_000]), ("B", &[0, 2_000_000])], + ); + log.push(penalty_applied( + "q-1", + "A", + Penalty::Disqualify { reason: None }, + )); + + let standings = round_standings(&meta, &round, &log).unwrap(); + let a = standing(&standings, "A"); + assert_eq!(a.position, 2, "the DQ sinks A below B"); + assert_eq!( + a.best_lap_micros, None, + "the DQ'd heat's lap is no best lap" + ); + assert_eq!(a.laps, 0, "the DQ'd heat's laps do not count"); + assert_eq!(a.metric, RoundMetric::BestLap { micros: None }); + let b = standing(&standings, "B"); + assert_eq!(b.position, 1); + assert_eq!(b.best_lap_micros, Some(2_000_000)); + } + + #[test] + fn dq_in_one_heat_keeps_the_pilots_clean_heats_in_the_standings() { + // Only the DQ'd heat is voided for the pilot: A's clean second heat still feeds the + // row, so their best lap is the CLEAN heat's 3.0s — not the DQ'd heat's faster 1.0s. + let round = qual_round("q1", "open"); // BestLap + let meta = meta_with(vec![round.clone()], vec![member("open", &["A", "B"])]); + let mut log = scored_heat( + "q-1", + "q1", + "open", + &[("A", &[0, 1_000_000]), ("B", &[0, 2_000_000])], + ); + log.extend(scored_heat( + "q-2", + "q1", + "open", + &[("A", &[0, 3_000_000]), ("B", &[0, 2_500_000])], + )); + log.push(penalty_applied( + "q-1", + "A", + Penalty::Disqualify { reason: None }, + )); + + let standings = round_standings(&meta, &round, &log).unwrap(); + let a = standing(&standings, "A"); + assert_eq!( + a.best_lap_micros, + Some(3_000_000), + "the clean heat counts; the DQ'd (faster) lap does not" + ); + assert_eq!(a.laps, 1, "only the clean heat's lap is counted"); + assert_eq!( + a.metric, + RoundMetric::BestLap { + micros: Some(3_000_000) + } + ); + } + + #[test] + fn ruling_reversed_restores_the_original_ranking() { + // RulingReversed un-applies a DQ at its true global LogRef (offsets PRESERVED, not + // re-enumerated): a DQ on A sinks A, then reversing that DQ restores the clean order. + let round = h2h_round("h2h", "open", WinCondition::FirstToLaps { n: 1 }); + let meta = meta_with(vec![round.clone()], vec![member("open", &["A", "B"])]); + let base = scored_heat( + "h2h-1", + "h2h", + "open", + &[("A", &[0, 1_000_000]), ("B", &[0, 2_000_000])], + ); + let clean = ranking_order(&round_ranking(&meta, &round, &base).unwrap()); + assert_eq!(clean, vec!["A", "B"]); + + // Apply a DQ on A — A sinks. Its append offset is the global `LogRef` a reversal targets. + let mut log = base.clone(); + let dq_offset = log.len() as u64; + log.push(penalty_applied( + "h2h-1", + "A", + Penalty::Disqualify { reason: None }, + )); + assert_eq!( + ranking_order(&round_ranking(&meta, &round, &log).unwrap()), + vec!["B", "A"] + ); + + // Reverse the DQ at its append offset — the original order is restored. A re-enumerated + // window would target the wrong offset and fail to restore. + log.push(Event::RulingReversed { + target: LogRef(dq_offset), + }); + assert_eq!( + ranking_order(&round_ranking(&meta, &round, &log).unwrap()), + clean + ); + } + + // --- Mis-windowing regressions: marshaling a NON-LATEST heat routes by tag/target ------- + // + // `heat_window_offsets` used to attribute every marshaling event *positionally* — to + // whichever heat was active at that point in the log. Adjudicating a FINISHED heat while a + // later heat ran was therefore a silent no-op on its target heat AND leaked into the live + // one. These pin the tag/target routing (app.rs `heat_window_offsets`) and the + // corrected-pass fold in `score_heat_window`; each fails on the old positional path. + + #[test] + fn a_restarted_heat_scores_only_its_current_run() { + // A heat races (run 1), is RESTARTED, and re-races (run 2). The abandoned run's passes + // — and a ruling made about them before the restart — must not reach the result: before + // the current-run window rule the scorer folded BOTH runs, and the ghost run's laps + // out-ranked the real ones (hit live 2026-07-03: a re-raced qualifier scored 39 ghost + // laps for one pilot AND held two positions at once). + let round = h2h_round("h2h", "open", WinCondition::FirstToLaps { n: 5 }); + let heat = "h2h-1"; + let mut log = vec![scheduled(heat, "h2h", "open", &["A", "B"])]; + // Run 1: A banks a pile of laps; a DQ on B lands mid-marshaling — all abandoned below. + log.push(changed(heat, HeatTransition::Staged)); + log.push(changed(heat, HeatTransition::Armed)); + log.push(changed(heat, HeatTransition::Running)); + for (i, t) in [0i64, 1_000_000, 2_000_000, 3_000_000].iter().enumerate() { + log.push(pass("A", *t, i as u64)); + } + log.push(changed(heat, HeatTransition::Finished)); + log.push(penalty_applied( + "h2h-1", + "B", + Penalty::Disqualify { reason: None }, + )); + // The RD abandons the run. + log.push(changed(heat, HeatTransition::Restarted)); + // Run 2: both fly clean — one lap each, A first. + log.push(changed(heat, HeatTransition::Staged)); + log.push(changed(heat, HeatTransition::Armed)); + log.push(changed(heat, HeatTransition::Running)); + log.push(pass("A", 10_000_000, 10)); + log.push(pass("A", 11_000_000, 11)); + log.push(pass("B", 10_100_000, 12)); + log.push(pass("B", 12_000_000, 13)); + log.push(changed(heat, HeatTransition::Finished)); + log.push(changed(heat, HeatTransition::Finalized)); + + let result = + crate::app::score_heat_window(&log, &HeatId(heat.into()), round.win_condition, None); + // Only run 2 scores: one lap each (holeshot + one), NOT run 1's ghost pile. + let by_ref: std::collections::BTreeMap<&str, u32> = result + .places + .iter() + .map(|p| (p.competitor.competitor.0.as_str(), p.laps)) + .collect(); + assert_eq!( + by_ref.get("A"), + Some(&1), + "A scores run 2's single lap only" + ); + assert_eq!( + by_ref.get("B"), + Some(&1), + "B scores run 2's single lap only" + ); + // The abandoned run's DQ does not survive the restart (clean slate). + assert!( + result.places.iter().all(|p| !p.disqualified), + "a pre-restart ruling belongs to the abandoned run" + ); + // A post-restart ruling DOES apply. + log.push(penalty_applied( + "h2h-1", + "B", + Penalty::Disqualify { reason: None }, + )); + let ruled = + crate::app::score_heat_window(&log, &HeatId(heat.into()), round.win_condition, None); + assert!( + ruled + .places + .iter() + .any(|p| p.competitor.competitor.0 == "B" && p.disqualified), + "a ruling on the CURRENT run applies" + ); + } + + #[test] + fn adjudicating_a_non_latest_heat_lands_in_its_own_window() { + // Two finished heats of one round; the DQ on heat 1's winner is appended AFTER heat 2's + // whole span (marshaling a finished heat). It must land in heat 1's window — not in + // whichever heat happened to run last. + let round = h2h_round("h2h", "open", WinCondition::FirstToLaps { n: 1 }); + let meta = meta_with( + vec![round.clone()], + vec![member("open", &["A", "B", "C", "D"])], + ); + let mut log = scored_heat( + "h2h-1", + "h2h", + "open", + &[("A", &[0, 1_000_000]), ("B", &[0, 2_000_000])], + ); + log.extend(scored_heat( + "h2h-2", + "h2h", + "open", + &[("C", &[0, 1_000_000]), ("D", &[0, 2_000_000])], + )); + log.push(penalty_applied( + "h2h-1", + "A", + Penalty::Disqualify { reason: None }, + )); + + // Heat 1's result carries the DQ... + let heat1 = + crate::app::score_heat_window(&log, &HeatId("h2h-1".into()), round.win_condition, None); + let dq: Vec<&str> = heat1 + .places + .iter() + .filter(|p| p.disqualified) + .map(|p| p.competitor.competitor.0.as_str()) + .collect(); + assert_eq!(dq, vec!["A"], "the DQ lands in the heat it names"); + // ...and heat 2 (the later, positionally-active heat) is untouched. + let heat2 = + crate::app::score_heat_window(&log, &HeatId("h2h-2".into()), round.win_condition, None); + assert!( + heat2.places.iter().all(|p| !p.disqualified), + "the DQ must not leak into the heat that happened to run last" + ); + // And the round ranking reflects it: A (heat 1's on-track winner) ranks last. + assert_eq!( + ranking_order(&round_ranking(&meta, &round, &log).unwrap()), + vec!["B", "C", "D", "A"] + ); + } + + #[test] + fn marshaling_lap_corrections_reach_the_round_ranking() { + // timed_qual BestLap: on track B (2.0s) beats A (5.0s). Marshaling then inserts A's + // missed pass at 2.5s (A best lap → 2.5s) and voids B's only lap-end detection (B → no + // lap). Ranking and standings must score the CORRECTED pass stream — before the fix + // `score_heat_window` scored raw passes only, so InsertLap/VoidDetection never reached + // results. + let round = qual_round("q1", "open"); // BestLap + let meta = meta_with(vec![round.clone()], vec![member("open", &["A", "B"])]); + let mut log = scored_heat( + "q-1", + "q1", + "open", + &[("A", &[0, 5_000_000]), ("B", &[0, 2_000_000])], + ); + // Sanity: the raw passes rank B (2.0s) ahead of A (5.0s). + assert_eq!( + ranking_order(&round_ranking(&meta, &round, &log).unwrap()), + vec!["B", "A"] + ); + + let b_lap_end = pass_offset(&log, "B", 2_000_000); + log.push(Event::LapInserted { + adapter: AdapterId(ADAPTER.into()), + competitor: CompetitorRef("A".into()), + at: SourceTime::from_micros(2_500_000), + heat: Some(HeatId("q-1".into())), + }); + log.push(Event::DetectionVoided { + target: LogRef(b_lap_end), + }); + + // The corrections land: A (2.5s best lap) now leads the lap-less B. + assert_eq!( + ranking_order(&round_ranking(&meta, &round, &log).unwrap()), + vec!["A", "B"] + ); + let standings = round_standings(&meta, &round, &log).unwrap(); + assert_eq!( + standing(&standings, "A").best_lap_micros, + Some(2_500_000), + "the inserted lap sets A's best lap" + ); + assert_eq!( + standing(&standings, "B").best_lap_micros, + None, + "voiding B's lap-end detection leaves B lap-less" + ); + } + + #[test] + fn protest_on_a_finished_heat_gates_that_heat_not_the_live_one() { + // Heat 1 is finished; heat 2 is mid-run when the protest against heat 1 is filed. The + // filing must join heat 1's window (where the audit/gating consumers read it) and stay + // out of the live heat 2's — positional attribution put it in heat 2's. + let mut log = scored_heat( + "h2h-1", + "h2h", + "open", + &[("A", &[0, 1_000_000]), ("B", &[0, 2_000_000])], + ); + log.push(scheduled("h2h-2", "h2h", "open", &["C", "D"])); + log.push(changed("h2h-2", HeatTransition::Staged)); + log.push(changed("h2h-2", HeatTransition::Armed)); + log.push(changed("h2h-2", HeatTransition::Running)); + log.push(pass("C", 10_000_000, 8)); + let protest_offset = log.len() as u64; + log.push(Event::ProtestFiled { + heat: HeatId("h2h-1".into()), + competitor: CompetitorRef("A".into()), + note: "blocking on lap 1".into(), + }); + log.push(pass("D", 11_000_000, 9)); // heat 2 races on around the filing + + let window1 = crate::app::heat_window_offsets(&log, &HeatId("h2h-1".into())); + assert!( + window1 + .iter() + .any(|(offset, e)| *offset == protest_offset + && matches!(e, Event::ProtestFiled { .. })), + "the protest gates the finished heat it names" + ); + let window2 = crate::app::heat_window_offsets(&log, &HeatId("h2h-2".into())); + assert!( + window2 + .iter() + .all(|(_, e)| !matches!(e, Event::ProtestFiled { .. })), + "the live heat is clear of the other heat's protest" + ); + } + + #[test] + fn two_rounds_of_the_same_format_never_collide_on_heat_ids() { + // Two head_to_head rounds over the same class: each generator emits h2h-h0/h2h-h1, so + // the LOGGED ids must be round-scoped (`{round}-{generator-id}`) — unscoped, the second + // round's heats would collide with the first's and every by-id fold (heat state, + // windows, live control) would conflate two different heats. + let mut ra = h2h_round("ra", "open", WinCondition::FirstToLaps { n: 1 }); + ra.params.insert("group_size".into(), "2".into()); + let mut rb = h2h_round("rb", "open", WinCondition::FirstToLaps { n: 1 }); + rb.params.insert("group_size".into(), "2".into()); + let meta = meta_with(vec![ra, rb], vec![member("open", &["A", "B", "C", "D"])]); + + // Drive both rounds through every fill, appending the tagged schedule + scored run the + // way the real handler does (mirrors fill_round_sequence_is_deterministic_on_replay). + let mut log: Vec = Vec::new(); + let mut ids: Vec = Vec::new(); + for round_id in ["ra", "rb"] { + for _ in 0..8 { + match fill_round(&meta, &no_timers(), &RoundId(round_id.into()), &log).unwrap() { + FillOutcome::Scheduled { heat, lineup, .. } => { + let names: Vec<&str> = lineup.iter().map(|c| c.0.as_str()).collect(); + log.push(scheduled(&heat.0, round_id, "open", &names)); + let mut passes = Vec::new(); + for (i, n) in names.iter().enumerate() { + passes.push(pass(n, i as i64, 0)); + passes.push(pass(n, 1_000_000 + i as i64, 1)); + } + log.extend(run_heat_events(&heat.0, passes)); + ids.push(heat.0); + } + FillOutcome::Complete => break, + other => panic!("unexpected fill outcome {other:?}"), + } + } + } + + // 4 pilots, 2-up, one rotation → two heats per round, all four ids distinct. + assert_eq!(ids.len(), 4, "two heats per round scheduled: {ids:?}"); + let unique: std::collections::HashSet<&String> = ids.iter().collect(); + assert_eq!( + unique.len(), + ids.len(), + "heat ids are globally unique across the two rounds: {ids:?}" + ); + // Each logged id carries its own round's scope prefix. + for id in &ids[..2] { + assert!(id.starts_with("ra-"), "round ra's heat is ra-scoped: {id}"); + } + for id in &ids[2..] { + assert!(id.starts_with("rb-"), "round rb's heat is rb-scoped: {id}"); + } + } + + #[test] + fn carry_seeding_freezes_at_first_fill_and_survives_source_adjudication() { + // FREEZE-AT-FILL (#334): once a FromRanking round fills, its field is a RECORDED draw — + // adjudicating the SOURCE round afterwards must not rewrite who the dependent round's + // field was (raced results would vanish from its ranking). A sibling round that has NOT + // yet filled keeps resolving live (build-ahead tracks its source until it draws). + let qual = qual_round("q1", "open"); + let mut fill_now = h2h_round("dep", "open", WinCondition::FirstToLaps { n: 1 }); + fill_now.seeding = SeedingRule::FromRanking { + source_rounds: vec![RoundId("q1".into())], + top_n: 2, + }; + fill_now.params.insert("group_size".into(), "2".into()); + let mut fill_later = h2h_round("dep2", "open", WinCondition::FirstToLaps { n: 1 }); + fill_later.seeding = SeedingRule::FromRanking { + source_rounds: vec![RoundId("q1".into())], + top_n: 2, + }; + fill_later.params.insert("group_size".into(), "2".into()); + let meta = meta_with( + vec![qual.clone(), fill_now.clone(), fill_later.clone()], + vec![member("open", &["A", "B", "C"])], + ); + + // Qualifier: A (1.0s) beats B (2.0s) beats C (3.0s) → top-2 carry = [A, B]. + let mut log = scored_heat( + "q-1", + "q1", + "open", + &[ + ("A", &[0, 1_000_000]), + ("B", &[0, 2_000_000]), + ("C", &[0, 3_000_000]), + ], + ); + + // Fill `dep` the way the real handler does: record the draw, then the heat. + let FillOutcome::Scheduled { + heat, + lineup, + field_draw, + .. + } = fill_round(&meta, &no_timers(), &fill_now.id, &log).unwrap() + else { + panic!("expected a scheduled heat"); + }; + let drawn = field_draw.expect("a carry seeding's first fill records its draw"); + assert_eq!(names_of(&drawn), vec!["A", "B"]); + log.push(Event::RoundFieldDrawn { + round: fill_now.id.clone(), + field: drawn, + }); + let names: Vec<&str> = lineup.iter().map(|c| c.0.as_str()).collect(); + log.push(scheduled(&heat.0, "dep", "open", &names)); + + // NOW the source adjudication lands: DQ A in the qualifier — its live ranking becomes + // [B, C] and a live top-2 carry would be [B, C]. + log.push(penalty_applied( + "q-1", + "A", + Penalty::Disqualify { reason: None }, + )); + + // The FILLED round's field is frozen at its recorded draw… + assert_eq!( + names_of(&round_field(&meta, &fill_now, &log).unwrap()), + vec!["A", "B"], + "the filled round keeps the field it actually raced" + ); + // …its next fill draws from the SAME frozen field (no second RoundFieldDrawn either)… + match fill_round(&meta, &no_timers(), &fill_now.id, &log).unwrap() { + FillOutcome::Scheduled { field_draw, .. } => { + assert!( + field_draw.is_none(), + "the draw is recorded once, not per heat" + ); + } + FillOutcome::Complete | FillOutcome::AlreadyScheduled => {} + } + // …while the NOT-yet-filled sibling resolves live and sees the adjudicated carry. + assert_eq!( + names_of(&round_field(&meta, &fill_later, &log).unwrap()), + vec!["B", "C"], + "an unfilled round keeps tracking its source until it draws" + ); + } + + #[test] + fn roster_seeding_stays_live_after_fill() { + // FromRoster never freezes (#334): a late entrant added to the class after the round + // filled still joins the round's field (and with it the ranking's no-value tail). + let round = h2h_round("r1", "open", WinCondition::FirstToLaps { n: 1 }); + let meta_before = meta_with(vec![round.clone()], vec![member("open", &["A", "B"])]); + let mut log: Vec = Vec::new(); + match fill_round(&meta_before, &no_timers(), &round.id, &log).unwrap() { + FillOutcome::Scheduled { + heat, + lineup, + field_draw, + .. + } => { + assert!(field_draw.is_none(), "a roster seeding records no draw"); + let names: Vec<&str> = lineup.iter().map(|c| c.0.as_str()).collect(); + log.push(scheduled(&heat.0, "r1", "open", &names)); + } + other => panic!("unexpected fill outcome {other:?}"), + } + // The late entrant lands in the membership; the round's field follows live. + let meta_after = meta_with(vec![round.clone()], vec![member("open", &["A", "B", "C"])]); + assert_eq!( + names_of(&round_field(&meta_after, &round, &log).unwrap()), + vec!["A", "B", "C"] + ); + } + + /// The bare names of a resolved field (freeze-at-fill test helper). + fn names_of(field: &[CompetitorRef]) -> Vec<&str> { + field.iter().map(|c| c.0.as_str()).collect() + } + + /// A `RankEntry` for a competitor at a 1-based position (aggregation test helper). + fn rank(competitor: &str, position: u32) -> RankEntry { + RankEntry { + competitor: CompetitorRef(competitor.into()), + position, + } + } + + #[test] + fn aggregate_rankings_of_a_single_round_is_that_ranking() { + // One source round → the merged ranking is that round's ranking unchanged. + let r1 = vec![rank("A", 1), rank("B", 2), rank("C", 3)]; + let merged = aggregate_rankings(std::slice::from_ref(&r1)); + assert_eq!(merged, r1); + } + + #[test] + fn aggregate_rankings_takes_each_pilots_best_position_across_rounds() { + // A placed 1 then 3 → best 1; B placed 2 then 2 → best 2; C placed 3 then 1 → best 1. + // A and C tie at best-position 1 (dense, tie-aware: 1, 1, then B at 3). + let r1 = vec![rank("A", 1), rank("B", 2), rank("C", 3)]; + let r2 = vec![rank("C", 1), rank("B", 2), rank("A", 3)]; + let merged = aggregate_rankings(&[r1, r2]); + assert_eq!( + merged, + vec![ + rank("A", 1), // best 1, ref tie-break before C + rank("C", 1), // best 1 + rank("B", 3), // best 2 → dense position 3 (skips past the two 1s) + ] + ); + } + + #[test] + fn aggregate_rankings_includes_pilots_present_in_only_some_rounds() { + // D only raced round 2; they still seed from their best (and only) position there. + let r1 = vec![rank("A", 1), rank("B", 2)]; + let r2 = vec![rank("D", 1), rank("A", 2)]; + let merged = aggregate_rankings(&[r1, r2]); + // A best 1, D best 1 (tie, ref order A then D), B best 2 → position 3. + assert_eq!(merged, vec![rank("A", 1), rank("D", 1), rank("B", 3)]); + } + + #[test] + fn aggregate_rankings_is_deterministic_regardless_of_round_order() { + let r1 = vec![rank("A", 1), rank("B", 2), rank("C", 3)]; + let r2 = vec![rank("C", 1), rank("B", 2), rank("A", 3)]; + assert_eq!( + aggregate_rankings(&[r1.clone(), r2.clone()]), + aggregate_rankings(&[r2, r1]) + ); + } + + /// An **open-practice** round fixture (open-practice format, Slice 1): `format: "open_practice"` + /// + `seeding: AllChannels { channels }`, with no eligible classes (it is not a class round). + fn open_practice_round(id: &str, channels: &[usize]) -> RoundDef { + RoundDef { + id: RoundId(id.into()), + label: id.into(), + classes: vec![], + format: "open_practice".into(), + params: BTreeMap::new(), + win_condition: WinCondition::BestLap, + seeding: SeedingRule::AllChannels { + channels: channels.to_vec(), + }, + channel_mode: ChannelMode::PerHeat, + staging_timer_secs: default_staging_timer_secs(), + start_procedure: StartProcedure::default(), + grace_window: default_grace_window(), + protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, + time_limit_secs: None, + } + } + + #[test] + fn open_practice_round_emits_one_heat_over_the_channels_with_empty_frequencies() { + // The active channels (node indices) become `node-{i}` lineup refs; the one open heat + // carries them with EMPTY frequencies (the lineup *is* the channels — nothing to allocate). + let round = open_practice_round("op1", &[0, 2, 5]); + let meta = meta_with(vec![round], vec![]); + match fill_round(&meta, &no_timers(), &RoundId("op1".into()), &[]).unwrap() { + FillOutcome::Scheduled { + heat, + lineup, + frequencies, + .. + } => { + // Issue #54: the heat id is **round-scoped** (`-heat`), not the generator's + // fixed `"open-practice"`, so two open-practice rounds get distinct heats. + assert_eq!(heat.0, "op1-heat"); + assert_eq!( + lineup, + vec![ + CompetitorRef("node-0".into()), + CompetitorRef("node-2".into()), + CompetitorRef("node-5".into()), + ] + ); + assert_eq!( + frequencies, + Some(Vec::new()), + "an open-practice heat carries empty frequencies" + ); + } + other => panic!("expected a scheduled open-practice heat, got {other:?}"), + } + } + + #[test] + fn open_practice_round_completes_after_its_one_heat() { + // After the single open heat is scheduled + driven to Final, the next FillRound is Complete + // (open practice is one heat, ever — no advancement). + let round = open_practice_round("op1", &[0, 1]); + let meta = meta_with(vec![round], vec![]); + let mut log = vec![scheduled("op1-heat", "op1", "open", &["node-0", "node-1"])]; + // The schedule above tags a class for the test helper; re-tag without a class is unnecessary + // — `finalized_heat_ids` keys on the round, not the class. + log.extend(run_heat_events("op1-heat", vec![])); + let next = fill_round(&meta, &no_timers(), &RoundId("op1".into()), &log).unwrap(); + assert_eq!(next, FillOutcome::Complete); + } + + #[test] + fn two_open_practice_rounds_yield_distinct_round_scoped_heat_ids() { + // Issue #54: two open-practice rounds in one event must auto-create **two distinct** heats. + // Before the fix both claimed the generator's fixed `"open-practice"` id, so the second round + // got no heat of its own. The id is now derived from the round id. + let op1 = open_practice_round("op1", &[0, 1]); + let op2 = open_practice_round("op2", &[2, 3]); + let meta = meta_with(vec![op1, op2], vec![]); + + let heat1 = match fill_round(&meta, &no_timers(), &RoundId("op1".into()), &[]).unwrap() { + FillOutcome::Scheduled { heat, .. } => heat, + other => panic!("expected op1 to schedule a heat, got {other:?}"), + }; + let heat2 = match fill_round(&meta, &no_timers(), &RoundId("op2".into()), &[]).unwrap() { + FillOutcome::Scheduled { heat, .. } => heat, + other => panic!("expected op2 to schedule a heat, got {other:?}"), + }; + + assert_eq!(heat1.0, "op1-heat"); + assert_eq!(heat2.0, "op2-heat"); + assert_ne!( + heat1, heat2, + "each open-practice round gets its own heat id" + ); + } + + #[test] + fn is_open_practice_recognizes_only_the_open_practice_format_plus_allchannels() { + // Both the format name AND AllChannels seeding are required. + assert!(is_open_practice(&open_practice_round("op", &[0]))); + // A normal qual round is not open-practice. + assert!(!is_open_practice(&qual_round("q", "open"))); + // The format name alone (with FromRoster) is not enough. + let mut mis = open_practice_round("op", &[0]); + mis.seeding = SeedingRule::FromRoster; + assert!(!is_open_practice(&mis)); + } + + #[test] + fn round_completes_after_its_configured_rounds() { + // A 1-round timed_qual: after one scored heat, the next FillRound is Complete. + let round = qual_round("q1", "open"); + let meta = meta_with(vec![round], vec![member("open", &["A", "B"])]); + + // The first heat the generator emits is the rotation-1 chunk (`tq-r1-h1`). + let first = fill_round(&meta, &no_timers(), &RoundId("q1".into()), &[]).unwrap(); + let heat_id = match first { + FillOutcome::Scheduled { heat, .. } => heat.0, + other => panic!("expected scheduled, got {other:?}"), + }; + + // Append the tagged schedule + drive it to Final with some passes. + let mut log = vec![scheduled(&heat_id, "q1", "open", &["A", "B"])]; + log.extend(run_heat_events( + &heat_id, + vec![ + pass("A", 0, 0), + pass("B", 100, 0), + pass("A", 1_500_000, 1), + pass("B", 1_700_000, 1), + ], + )); + + // Now the round is complete (1 round configured, 1 scored). + let next = fill_round(&meta, &no_timers(), &RoundId("q1".into()), &log).unwrap(); + assert_eq!(next, FillOutcome::Complete); + + // And the round has a final ranking — A (faster lap) ahead of B. + let round = &meta.rounds[0]; + let ranking = round_ranking(&meta, round, &log).unwrap(); + assert_eq!(ranking[0].competitor, CompetitorRef("A".into())); + assert_eq!(ranking[1].competitor, CompetitorRef("B".into())); + } + + /// Finalize a heat where `order` is its finishing order (best first): each pilot gets a holeshot + /// then one lap, with lap times strictly increasing down the order so BestLap ranks them as + /// listed. Appends the full schedule→run→finalize span under `heat_id`. + fn finishing_heat(heat_id: &str, round: &str, class: &str, order: &[&str]) -> Vec { + let mut out = vec![scheduled(heat_id, round, class, order)]; + let mut passes = Vec::new(); + for (i, c) in order.iter().enumerate() { + passes.push(pass(c, i as i64, 0)); // holeshot (uncounted) + } + for (i, c) in order.iter().enumerate() { + // Best lap grows with position so the listed order is the finishing order. + passes.push(pass(c, 1_000_000 + (i as i64) * 100_000, 1)); + } + out.extend(run_heat_events(heat_id, passes)); + out + } + + // --- Static channel-balanced formation (race redesign Slice 7a) ------------------------ + + /// A `timed_qual` round in **Static** channel mode (one format-round) over `class`. + fn static_qual_round(id: &str, class: &str) -> RoundDef { + RoundDef { + id: RoundId(id.into()), + label: id.into(), + classes: vec![ScopeClassId(class.into())], + format: "timed_qual".into(), + params: BTreeMap::from([("rounds".into(), "1".into())]), + win_condition: WinCondition::BestLap, + seeding: SeedingRule::FromRoster, + channel_mode: ChannelMode::Static, + staging_timer_secs: default_staging_timer_secs(), + start_procedure: StartProcedure::default(), + grace_window: default_grace_window(), + protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, + time_limit_secs: None, + } + } + + #[test] + fn static_round_forms_channel_balanced_heats_over_node_cap() { + // 20 members spread across 8 Raceband channels on a 4-node timer (channels > node_count): + // every heat has ≤ 4 pilots on DISTINCT channels, and every member flies. The channel pool + // (8) exceeds the node cap (4) — node_count caps only pilots/heat, never the channel count. + let r1 = RACEBAND_MHZ[0]; + let r2 = RACEBAND_MHZ[1]; + let r3 = RACEBAND_MHZ[2]; + let r4 = RACEBAND_MHZ[3]; + let r5 = RACEBAND_MHZ[4]; + let r6 = RACEBAND_MHZ[5]; + let r7 = RACEBAND_MHZ[6]; + let r8 = RACEBAND_MHZ[7]; + let channels = [r1, r2, r3, r4, r5, r6, r7, r8]; + // 20 pilots, 2-3 per channel. + let members: Vec<(&str, u16)> = + (0..20).map(|i| (PILOT_NAMES[i], channels[i % 8])).collect(); + let round = static_qual_round("q1", "open"); + let (meta, timers) = meta_with_timer( + vec![round], + vec![member_chan("open", &members)], + 4, // node cap + ); + + // Drive every static heat the round wants, collecting each scheduled heat's assignment. + let mut log: Vec = Vec::new(); + let mut heats: Vec> = Vec::new(); + for _ in 0..50 { + match fill_round(&meta, &timers, &RoundId("q1".into()), &log).unwrap() { + FillOutcome::Scheduled { + heat, + lineup, + frequencies, + .. + } => { + let freqs = frequencies.expect("static round assigns channels itself"); + // Each heat is ≤ node cap and channel-distinct. + assert!(lineup.len() <= 4, "heat exceeds the 4-node cap: {lineup:?}"); + let mut seen = std::collections::BTreeSet::new(); + for (_, ch) in &freqs { + assert!(seen.insert(*ch), "duplicate channel {ch} in one heat"); + } + // Lineup matches the assignment competitors. + let lineup_set: Vec<&str> = lineup.iter().map(|c| c.0.as_str()).collect(); + let freq_set: Vec<&str> = freqs.iter().map(|(c, _)| c.0.as_str()).collect(); + assert_eq!(lineup_set, freq_set); + heats.push(freqs.clone()); + // Schedule + score the heat so the next FillRound advances. + let names: Vec = lineup.iter().map(|c| c.0.clone()).collect(); + log.push(Event::HeatScheduled { + heat: heat.clone(), + lineup, + class: Some(ClassId("open".into())), + round: Some(RoundId("q1".into())), + frequencies: freqs, + label: None, + }); + let mut passes = Vec::new(); + for (i, n) in names.iter().enumerate() { + passes.push(pass(n, i as i64, 0)); + passes.push(pass(n, 1_000_000 + i as i64, 1)); + } + log.extend(run_heat_events(&heat.0, passes)); + } + FillOutcome::Complete => break, + other => panic!("unexpected static outcome {other:?}"), + } + } + + // Every one of the 20 members flew exactly once across the round. + let flown: std::collections::BTreeSet<&str> = heats + .iter() + .flat_map(|h| h.iter().map(|(c, _)| c.0.as_str())) + .collect(); + assert_eq!(flown.len(), 20, "every member flies"); + // The channel pool used spans all 8 channels (> the 4-node cap). + let used_channels: std::collections::BTreeSet = heats + .iter() + .flat_map(|h| h.iter().map(|(_, ch)| *ch)) + .collect(); + assert_eq!(used_channels.len(), 8, "channels span the full 8-wide pool"); + } + + #[test] + fn static_round_missing_channel_is_a_typed_error() { + // A Static round with a member lacking a channel is a clear MissingChannel error. + let round = static_qual_round("q1", "open"); + let (meta, timers) = meta_with_timer( + vec![round], + vec![ClassMembership { + class: ScopeClassId("open".into()), + pilots: vec![ + MemberSlot { + pilot: PilotId("A".into()), + channel: Some(RACEBAND_MHZ[0]), + }, + MemberSlot::new(PilotId("B".into())), // no channel + ], + }], + 4, + ); + assert!(matches!( + fill_round(&meta, &timers, &RoundId("q1".into()), &[]), + Err(FillError::MissingChannel(_)) + )); + } + + #[test] + fn static_round_is_deterministic_on_replay() { + let members: Vec<(&str, u16)> = (0..6) + .map(|i| (PILOT_NAMES[i], RACEBAND_MHZ[i % 3])) + .collect(); + let round = static_qual_round("q1", "open"); + let (meta, timers) = meta_with_timer(vec![round], vec![member_chan("open", &members)], 4); + let once = fill_round(&meta, &timers, &RoundId("q1".into()), &[]).unwrap(); + let twice = fill_round(&meta, &timers, &RoundId("q1".into()), &[]).unwrap(); + assert_eq!(once, twice); + } + + /// Twenty stable pilot callsigns for the static formation tests. + const PILOT_NAMES: [&str; 20] = [ + "p00", "p01", "p02", "p03", "p04", "p05", "p06", "p07", "p08", "p09", "p10", "p11", "p12", + "p13", "p14", "p15", "p16", "p17", "p18", "p19", + ]; + + // --- Per-class standings (race redesign Slice 5/6a) ------------------------------------ + + /// A complete scored qual heat for a round + class with four pilots A>B>C>D on best lap, + /// returning the round-tagged schedule plus the run-to-Final events. + fn scored_qual_heat(heat: &str, round: &str, class: &str, names: &[&str]) -> Vec { + let mut log = vec![scheduled(heat, round, class, names)]; + // Holeshot for all, then a distinct lap per pilot so the best-lap order is AB>C>D, points = field-pos+1. + let round = qual_round("q1", "open"); + let meta = meta_with(vec![round], vec![member("open", &["A", "B", "C", "D"])]); + let log = scored_qual_heat("q-1", "q1", "open", &["A", "B", "C", "D"]); + + let standings = class_standings(&meta, &ClassId("open".into()), &log).unwrap(); + let order: Vec<&str> = standings + .standings + .iter() + .map(|s| s.competitor.0.as_str()) + .collect(); + assert_eq!(order, vec!["A", "B", "C", "D"], "best-lap order"); + // 4-pilot field: 1st=4pts, 2nd=3, 3rd=2, 4th=1. + assert_eq!(standings.standings[0].points, 4); + assert_eq!(standings.standings[3].points, 1); + assert_eq!(standings.standings[0].position, 1); + assert_eq!(standings.standings[3].position, 4); + // A's best lap is the fastest (1.0s) and they ran the one round. + assert_eq!(standings.standings[0].best_lap_micros, Some(1_000_000)); + assert_eq!(standings.standings[0].rounds_entered, 1); + assert_eq!(standings.standings[0].total_laps, 1); + } + + /// A `PenaltyApplied { PointsDeducted }` for a competitor in a heat. + fn points_deducted(heat: &str, competitor: &str, points: u32) -> Event { + Event::PenaltyApplied { + heat: HeatId(heat.into()), + competitor: CompetitorRef(competitor.into()), + penalty: gridfpv_events::Penalty::PointsDeducted { points }, + } + } + + #[test] + fn class_standings_apply_points_deduction_to_the_season_total() { + // Slice 6: a points deduction shifts the *standings* total (not the per-heat lap result). + // A wins the round (4 pts) but is docked 3 → 1 pt, sinking A below B(3) and C(2). The + // re-fold is deterministic and order-independent (the deduction is keyed by competitor). + let round = qual_round("q1", "open"); + let meta = meta_with(vec![round], vec![member("open", &["A", "B", "C", "D"])]); + let mut log = scored_qual_heat("q-1", "q1", "open", &["A", "B", "C", "D"]); + log.push(points_deducted("q-1", "A", 3)); // dock A 3 points + + let standings = class_standings(&meta, &ClassId("open".into()), &log).unwrap(); + let a = standings + .standings + .iter() + .find(|s| s.competitor.0 == "A") + .unwrap(); + assert_eq!(a.points, 1, "4 round points − 3 deducted"); + // B (3 pts) now leads; A drops to 1 pt — tied with D (also 1 pt), but A's faster best lap + // breaks the tie ahead of D, so the docked A lands at position 3 (B, C, A, D). + assert_eq!(standings.standings[0].competitor.0, "B"); + assert_eq!(a.position, 3); + let d = standings + .standings + .iter() + .find(|s| s.competitor.0 == "D") + .unwrap(); + assert_eq!(d.points, 1); + assert_eq!( + d.position, 4, + "D ties A on points but loses the best-lap tie-break" + ); + // Deterministic on replay. + assert_eq!( + class_standings(&meta, &ClassId("open".into()), &log).unwrap(), + standings + ); + } + + #[test] + fn class_standings_points_deduction_saturates_at_zero_and_reversal_restores() { + // A huge deduction floors the total at zero (never negative); reversing the deduction + // (generalized `RulingReversed`) restores the original points — both standings-only. + let round = qual_round("q1", "open"); + let meta = meta_with(vec![round], vec![member("open", &["A", "B"])]); + let base = scored_qual_heat("q-1", "q1", "open", &["A", "B"]); + + // Floor at zero: deduct 100 from A (who earned 2 round points). + let mut floored = base.clone(); + floored.push(points_deducted("q-1", "A", 100)); + let s = class_standings(&meta, &ClassId("open".into()), &floored).unwrap(); + let a = s.standings.iter().find(|x| x.competitor.0 == "A").unwrap(); + assert_eq!(a.points, 0, "saturates at zero, never negative"); + + // Reverse the deduction (target its offset, the last event) → A's points restored. + let mut reversed = floored.clone(); + let deduction_offset = (floored.len() - 1) as u64; + reversed.push(Event::RulingReversed { + target: gridfpv_events::LogRef(deduction_offset), + }); + let s2 = class_standings(&meta, &ClassId("open".into()), &reversed).unwrap(); + let a2 = s2.standings.iter().find(|x| x.competitor.0 == "A").unwrap(); + assert_eq!(a2.points, 2, "reversing the deduction restores the points"); + } + + #[test] + fn class_standings_points_added_and_deducted_net_out() { + // PointsAdded and PointsDeducted on the same competitor net together (order-independent). + let round = qual_round("q1", "open"); + let meta = meta_with(vec![round], vec![member("open", &["A", "B"])]); + let mut log = scored_qual_heat("q-1", "q1", "open", &["A", "B"]); + // A earned 2 round points; +3 then −1 nets +2 → 4 total. + log.push(Event::PenaltyApplied { + heat: HeatId("q-1".into()), + competitor: CompetitorRef("A".into()), + penalty: gridfpv_events::Penalty::PointsAdded { points: 3 }, + }); + log.push(points_deducted("q-1", "A", 1)); + let s = class_standings(&meta, &ClassId("open".into()), &log).unwrap(); + let a = s.standings.iter().find(|x| x.competitor.0 == "A").unwrap(); + assert_eq!(a.points, 4, "2 + 3 − 1"); + } + + #[test] + fn class_standings_points_deduction_is_scoped_to_the_class_heat() { + // A pilot "A" races BOTH classes. A points deduction recorded against the OPEN heat must + // dock A only in the open standings, never leak into the sport standings (Slice 6 fix). + let open = qual_round("q1", "open"); + let mut sport = qual_round("q2", "sport"); + sport.classes = vec![ScopeClassId("sport".into())]; + let meta = meta_with( + vec![open, sport], + vec![member("open", &["A", "B"]), member("sport", &["A", "B"])], + ); + let mut log = scored_qual_heat("q1-1", "q1", "open", &["A", "B"]); + log.extend(scored_qual_heat("q2-1", "q2", "sport", &["A", "B"])); + // Deduct 2 points from A, recorded against the OPEN heat (q1-1). + log.push(points_deducted("q1-1", "A", 2)); + + // Open: A earned 2 round points, docked 2 → 0. + let open_s = class_standings(&meta, &ClassId("open".into()), &log).unwrap(); + let open_a = open_s + .standings + .iter() + .find(|x| x.competitor.0 == "A") + .unwrap(); + assert_eq!(open_a.points, 0, "open deduction applies to open"); + // Sport: A's 2 round points are UNTOUCHED — the open-heat deduction must not leak. + let sport_s = class_standings(&meta, &ClassId("sport".into()), &log).unwrap(); + let sport_a = sport_s + .standings + .iter() + .find(|x| x.competitor.0 == "A") + .unwrap(); + assert_eq!( + sport_a.points, 2, + "open-heat deduction must not leak into sport" + ); + } + + #[test] + fn class_standings_aggregate_across_multiple_rounds() { + // Two qual rounds for the class; points accumulate across both. Same A>B>C>D each round, + // so A totals 8 points (4+4) and leads, D totals 2 (1+1) and trails. + let r1 = qual_round("q1", "open"); + let r2 = qual_round("q2", "open"); + let meta = meta_with(vec![r1, r2], vec![member("open", &["A", "B", "C", "D"])]); + let mut log = scored_qual_heat("q1-1", "q1", "open", &["A", "B", "C", "D"]); + log.extend(scored_qual_heat( + "q2-1", + "q2", + "open", + &["A", "B", "C", "D"], + )); + + let standings = class_standings(&meta, &ClassId("open".into()), &log).unwrap(); + assert_eq!(standings.standings[0].competitor, CompetitorRef("A".into())); + assert_eq!(standings.standings[0].points, 8, "4 + 4 across two rounds"); + assert_eq!(standings.standings[0].rounds_entered, 2); + assert_eq!(standings.standings[0].total_laps, 2, "one lap each round"); + assert_eq!( + standings.standings.last().unwrap().competitor, + CompetitorRef("D".into()) + ); + assert_eq!(standings.standings.last().unwrap().points, 2); + } + + #[test] + fn class_standings_exclude_other_classes_rounds() { + // Two classes each with their own round; the "open" standings cover only open's pilots. + let open = qual_round("q1", "open"); + let mut sport = qual_round("q2", "sport"); + sport.classes = vec![ScopeClassId("sport".into())]; + let meta = meta_with( + vec![open, sport], + vec![member("open", &["A", "B"]), member("sport", &["X", "Y"])], + ); + let mut log = scored_qual_heat("q1-1", "q1", "open", &["A", "B"]); + log.extend(scored_qual_heat("q2-1", "q2", "sport", &["X", "Y"])); + + let standings = class_standings(&meta, &ClassId("open".into()), &log).unwrap(); + let names: Vec<&str> = standings + .standings + .iter() + .map(|s| s.competitor.0.as_str()) + .collect(); + assert_eq!(names, vec!["A", "B"], "only open's pilots, not X/Y"); + } + + #[test] + fn class_standings_are_deterministic_on_replay() { + let round = qual_round("q1", "open"); + let meta = meta_with(vec![round], vec![member("open", &["A", "B", "C"])]); + let log = scored_qual_heat("q-1", "q1", "open", &["A", "B", "C"]); + let once = class_standings(&meta, &ClassId("open".into()), &log).unwrap(); + let twice = class_standings(&meta, &ClassId("open".into()), &log).unwrap(); + assert_eq!(once, twice); + } + + #[test] + fn class_standings_ignore_a_defined_but_unraced_round() { + // Two rounds are defined up front (qual + finals); only qual has raced. The unraced + // finals round used to seed the whole field tied at 1 and hand everyone field_size + // free points + a phantom rounds_entered — shifting the standings before a single + // finals heat ran. + let raced = qual_round("q1", "open"); + let unraced = qual_round("q2", "open"); + let meta = meta_with(vec![raced, unraced], vec![member("open", &["A", "B", "C"])]); + let log = scored_qual_heat("q1-1", "q1", "open", &["A", "B", "C"]); + let standings = class_standings(&meta, &ClassId("open".into()), &log).unwrap(); + for row in &standings.standings { + assert_eq!( + row.rounds_entered, 1, + "{:?} must count only the RACED round", + row.competitor + ); + } + // Winner points come from the one raced round only (field of 3 -> 3 points, not 6). + assert_eq!(standings.standings[0].points, 3); + } + + #[test] + fn class_standings_for_a_class_with_no_rounds_are_empty() { + let round = qual_round("q1", "open"); + let meta = meta_with(vec![round], vec![member("open", &["A", "B"])]); + // Query a class that has no round at all. + let standings = class_standings(&meta, &ClassId("nobody".into()), &[]).unwrap(); + assert!(standings.standings.is_empty()); + assert_eq!(standings.class, ClassId("nobody".into())); + } + + /// A **race** round (a `Timed` win condition) — the bug's reproduction case. Its placements + /// carry completion *times* (`Metric::LastLapAt`), not lap durations, so the standings best lap + /// must come from the lap-list projection, not the metric. + fn race_round(id: &str, class: &str) -> RoundDef { + RoundDef { + id: RoundId(id.into()), + label: id.into(), + classes: vec![ScopeClassId(class.into())], + format: "timed_qual".into(), + params: BTreeMap::from([("rounds".into(), "1".into())]), + win_condition: WinCondition::Timed { + window_micros: 60_000_000, + }, + seeding: SeedingRule::FromRoster, + channel_mode: ChannelMode::PerHeat, + staging_timer_secs: default_staging_timer_secs(), + start_procedure: StartProcedure::default(), + grace_window: default_grace_window(), + protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, + time_limit_secs: None, + } + } + + #[test] + fn class_standings_compute_best_lap_for_a_race_round() { + // Regression: a `Timed` race round scores completion *times* into its placement metric, not + // lap durations — so the old metric-reading best-lap left `best_lap_micros` null even though + // the heat had real per-pilot laps. The best lap must now be the minimum lap duration each + // pilot ran, folded from the same lap view `total_laps` is counted from. + let round = race_round("r1", "open"); + let meta = meta_with(vec![round], vec![member("open", &["A", "B", "C"])]); + + // Holeshot at t=0, then multi-lap runs with a known fastest lap per pilot: + // A: laps 2.50s, 2.20s, 2.40s → best 2.20s + // B: laps 2.30s, 2.60s → best 2.30s + // C: never completes a lap (one crossing only) → no best lap. + let passes = vec![ + pass("A", 0, 0), + pass("B", 0, 1), + pass("C", 0, 2), + pass("A", 2_500_000, 3), // A lap1 2.50s + pass("B", 2_300_000, 4), // B lap1 2.30s + pass("A", 4_700_000, 5), // A lap2 2.20s (A's fastest) + pass("B", 4_900_000, 6), // B lap2 2.60s + pass("A", 7_100_000, 7), // A lap3 2.40s + ]; + let mut log = vec![scheduled("r-1", "r1", "open", &["A", "B", "C"])]; + log.extend(run_heat_events("r-1", passes)); + + let standings = class_standings(&meta, &ClassId("open".into()), &log).unwrap(); + let by_name = |name: &str| { + standings + .standings + .iter() + .find(|s| s.competitor.0 == name) + .unwrap_or_else(|| panic!("{name} missing from standings")) + }; + + // The fix: each pilot's best lap is the minimum of their counted laps — not null. + assert_eq!(by_name("A").best_lap_micros, Some(2_200_000), "A min lap"); + assert_eq!(by_name("A").total_laps, 3); + assert_eq!(by_name("B").best_lap_micros, Some(2_300_000), "B min lap"); + assert_eq!(by_name("B").total_laps, 2); + // A pilot with no completed lap stays null. + assert_eq!(by_name("C").best_lap_micros, None, "C completed no lap"); + assert_eq!(by_name("C").total_laps, 0); + } + + #[test] + fn class_standings_best_lap_is_min_across_a_pilots_race_rounds() { + // Across two race rounds, the standings best lap is the minimum over both rounds' laps. + let r1 = race_round("r1", "open"); + let r2 = race_round("r2", "open"); + let meta = meta_with(vec![r1, r2], vec![member("open", &["A", "B"])]); + + // Round 1: A's fastest lap 2.40s. Round 2: A's fastest lap 2.10s → overall best 2.10s. + let mut log = vec![scheduled("r1-1", "r1", "open", &["A", "B"])]; + log.extend(run_heat_events( + "r1-1", + vec![ + pass("A", 0, 0), + pass("B", 0, 1), + pass("A", 2_400_000, 2), + pass("B", 2_700_000, 3), + ], + )); + log.push(scheduled("r2-1", "r2", "open", &["A", "B"])); + log.extend(run_heat_events( + "r2-1", + vec![ + pass("A", 0, 4), + pass("B", 0, 5), + pass("A", 2_100_000, 6), + pass("B", 2_900_000, 7), + ], + )); + + let standings = class_standings(&meta, &ClassId("open".into()), &log).unwrap(); + let a = standings + .standings + .iter() + .find(|s| s.competitor.0 == "A") + .unwrap(); + assert_eq!(a.best_lap_micros, Some(2_100_000), "min across both rounds"); + assert_eq!(a.total_laps, 2, "one lap each round"); + } + + #[test] + fn fill_round_is_deterministic_on_replay() { + let round = qual_round("q1", "open"); + let meta = meta_with(vec![round], vec![member("open", &["A", "B", "C"])]); + let once = fill_round(&meta, &no_timers(), &RoundId("q1".into()), &[]).unwrap(); + let twice = fill_round(&meta, &no_timers(), &RoundId("q1".into()), &[]).unwrap(); + assert_eq!(once, twice); + } + + /// Mirror of the engine's `run_event_is_deterministic_on_replay` for the FillRound-driven + /// sequence: a 2-round timed_qual replays the **same** sequence of fill outcomes off the + /// same log + meta — the determinism the round-driven engine inherits from the generator + /// contract (RE §6). + #[test] + fn fill_round_sequence_is_deterministic_on_replay() { + // A 2-round qual so the sequence has more than one fill step. + let mut round = qual_round("q1", "open"); + round.params = BTreeMap::from([("rounds".into(), "2".into())]); + let meta = meta_with(vec![round], vec![member("open", &["A", "B", "C"])]); + + // Drive the full FillRound sequence over a freshly-built log, recording each outcome. + let drive = || -> Vec { + let mut log: Vec = Vec::new(); + let mut outcomes = Vec::new(); + for _ in 0..4 { + let outcome = fill_round(&meta, &no_timers(), &RoundId("q1".into()), &log).unwrap(); + outcomes.push(outcome.clone()); + if let FillOutcome::Scheduled { heat, lineup, .. } = outcome { + let heat_id = heat.0.clone(); + log.push(Event::HeatScheduled { + heat, + lineup, + class: Some(ClassId("open".into())), + round: Some(RoundId("q1".into())), + frequencies: vec![], + label: None, + }); + // Score the heat with deterministic passes (A < B < C on best lap). + log.extend(run_heat_events( + &heat_id, + vec![ + pass("A", 0, 0), + pass("B", 10, 0), + pass("C", 20, 0), + pass("A", 1_000_000, 1), + pass("B", 1_200_000, 1), + pass("C", 1_400_000, 1), + ], + )); + } + } + outcomes + }; + + let first = drive(); + let second = drive(); + assert_eq!(first, second, "the FillRound sequence replays identically"); + // The sequence is: schedule r1, schedule r2, then Complete (and stays Complete). + assert!(matches!(first[0], FillOutcome::Scheduled { .. })); + assert!(matches!(first[1], FillOutcome::Scheduled { .. })); + assert_eq!(first[2], FillOutcome::Complete); + assert_eq!(first[3], FillOutcome::Complete); + } + + // --- Multi-main seeding: FromRankingRange + Combine (depth-guarded) -------------------- + + /// A round seeded by an arbitrary `seeding` rule over the `open` class — for the + /// `FromRankingRange` / `Combine` field tests. Format/win-condition are inert here (only the + /// field builder is exercised), so `head_to_head` + a sensible win condition keep it valid. + fn seeded_round(id: &str, seeding: SeedingRule) -> RoundDef { + RoundDef { + id: RoundId(id.into()), + label: id.into(), + classes: vec![ScopeClassId("open".into())], + format: "head_to_head".into(), + params: BTreeMap::new(), + win_condition: WinCondition::FirstToLaps { n: 1 }, + seeding, + channel_mode: ChannelMode::PerHeat, + staging_timer_secs: default_staging_timer_secs(), + start_procedure: StartProcedure::default(), + grace_window: default_grace_window(), + protest_window: gridfpv_engine::heat::ProtestWindow::Off, + min_lap_secs: None, + time_limit_secs: None, + } + } + + /// A finalized `q1` qual heat ranking the listed pilots best-first (increasing best laps). + fn qual_ranking_log(order: &[&str]) -> Vec { + finishing_heat("q-1", "q1", "open", order) + } + + #[test] + fn from_ranking_range_resolves_the_right_slice() { + // q1 ranks A>B>C>D>E>F>G>H. A FromRankingRange{skip:2, take:3} is the window seeds 3–5. + let qual = qual_round("q1", "open"); + let rr = seeded_round( + "rr", + SeedingRule::FromRankingRange { + source_rounds: vec![RoundId("q1".into())], + skip: 2, + take: 3, + }, + ); + let meta = meta_with( + vec![qual, rr.clone()], + vec![member("open", &["A", "B", "C", "D", "E", "F", "G", "H"])], + ); + let log = qual_ranking_log(&["A", "B", "C", "D", "E", "F", "G", "H"]); + + let field = round_field(&meta, &rr, &log).unwrap(); + assert_eq!( + field, + lineup(&["C", "D", "E"]), + "FromRankingRange takes the skip..skip+take window of the merged ranking" + ); + } + + #[test] + fn combine_concatenates_and_dedupes_keeping_first_occurrence() { + // q1 ranks A>B>C>D. Combine[ FromRankingRange{skip:2,take:2} (→ C,D), + // FromRanking{top_n:3} (→ A,B,C) ] concatenates C,D,A,B,C and dedupes first-wins → C,D,A,B. + // C is named by both sources; it is seeded once, at the *earlier* source's position. + let qual = qual_round("q1", "open"); + let combined = seeded_round( + "cmb", + SeedingRule::Combine { + sources: vec![ + SeedingRule::FromRankingRange { + source_rounds: vec![RoundId("q1".into())], + skip: 2, + take: 2, + }, + SeedingRule::FromRanking { + source_rounds: vec![RoundId("q1".into())], + top_n: 3, + }, + ], + }, + ); + let meta = meta_with( + vec![qual, combined.clone()], + vec![member("open", &["A", "B", "C", "D"])], + ); + let log = qual_ranking_log(&["A", "B", "C", "D"]); + + let field = round_field(&meta, &combined, &log).unwrap(); + assert_eq!( + field, + lineup(&["C", "D", "A", "B"]), + "Combine concatenates in order then dedupes keeping each ref's first occurrence" + ); + } + + #[test] + fn combine_over_a_provisional_source_matches_from_ranking_gating() { + // Gating parity: with q1 NOT yet run (no finalized heat), its ranking is provisional. A + // Combine wrapping a FromRanking yields exactly what the bare FromRanking does over the same + // provisional source — the carry is not gated on the source being Final (same as FromRanking). + let qual = qual_round("q1", "open"); + let bare = seeded_round( + "bare", + SeedingRule::FromRanking { + source_rounds: vec![RoundId("q1".into())], + top_n: 2, + }, + ); + let wrapped = seeded_round( + "wrapped", + SeedingRule::Combine { + sources: vec![SeedingRule::FromRanking { + source_rounds: vec![RoundId("q1".into())], + top_n: 2, + }], + }, + ); + let meta = meta_with( + vec![qual, bare.clone(), wrapped.clone()], + vec![member("open", &["A", "B", "C", "D"])], + ); + // Empty log → q1 has no finalized heat → its ranking is the provisional whole-field order. + let log: Vec = vec![]; + + let bare_field = round_field(&meta, &bare, &log).unwrap(); + let wrapped_field = round_field(&meta, &wrapped, &log).unwrap(); + assert!( + !bare_field.is_empty(), + "a provisional source still yields a (provisional) field" + ); + assert_eq!( + wrapped_field, bare_field, + "Combine over a provisional source matches the bare FromRanking carry" + ); + } + + #[test] + fn deeply_nested_combine_is_rejected_as_too_deep() { + // Nest Combine far past MAX_SEEDING_DEPTH; resolving must return SeedingTooDeep, not overflow. + let mut seeding = SeedingRule::FromRoster; + for _ in 0..(crate::events::MAX_SEEDING_DEPTH + 2) { + seeding = SeedingRule::Combine { + sources: vec![seeding], + }; + } + let round = seeded_round("deep", seeding); + let meta = meta_with(vec![round.clone()], vec![member("open", &["A", "B"])]); + + assert_eq!( + round_field(&meta, &round, &[]), + Err(FillError::SeedingTooDeep), + "an over-deep Combine is rejected, not a stack overflow" + ); + } + + #[test] + fn cross_round_seeding_cycle_is_rejected_as_too_deep() { + // A seeds FromRanking B, B seeds FromRanking A — a mutual cycle. The shared depth thread + // through round_ranking→resolve_seeding bounds it: SeedingTooDeep instead of a stack overflow. + let a = seeded_round( + "ra", + SeedingRule::FromRanking { + source_rounds: vec![RoundId("rb".into())], + top_n: 2, + }, + ); + let b = seeded_round( + "rb", + SeedingRule::FromRanking { + source_rounds: vec![RoundId("ra".into())], + top_n: 2, + }, + ); + let meta = meta_with( + vec![a.clone(), b.clone()], + vec![member("open", &["A", "B", "C", "D"])], + ); + + assert_eq!( + round_field(&meta, &a, &[]), + Err(FillError::SeedingTooDeep), + "a 2-round seeding cycle terminates with SeedingTooDeep" + ); + } +} diff --git a/crates/server/src/scope.rs b/crates/server/src/scope.rs new file mode 100644 index 0000000..3cb7197 --- /dev/null +++ b/crates/server/src/scope.rs @@ -0,0 +1,194 @@ +//! 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; + +/// The event-scoped pilot handle a scope addresses (protocol.html §4 "Pilot scope") — a +/// racer following their own laps, results, and next heat. +/// +/// This is the **canonical** [`PilotId`](gridfpv_events::PilotId) re-exported from the +/// event model, so the wire/scope layer and the logged registration binding +/// ([`Event::CompetitorRegistered`](gridfpv_events::Event::CompetitorRegistered)) share +/// one type — 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), now modelled in the log +/// (#60). +pub use gridfpv_events::PilotId; + +/// 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. +/// +/// This is the **canonical** [`ClassId`](gridfpv_events::ClassId) re-exported from the +/// event model — the same type the log uses to tag a scheduled heat with its class +/// ([`Event::HeatScheduled`](gridfpv_events::Event::HeatScheduled)) — so the wire/scope +/// layer and the log never disagree on what a class id is. Mirrors how +/// [`PilotId`](gridfpv_events::PilotId) is shared. +pub use gridfpv_events::ClassId; + +/// 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. +/// 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. +#[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, + /// 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). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub contract_version: Option, + /// 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). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub token: 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, + contract_version: None, + token: None, + }; + let json = serde_json::to_string(&fresh).unwrap(); + // A fresh subscribe omits `from`, `contract_version`, and `token` entirely + // (skip_serializing_if), so it is byte-identical to the pre-#46 shape. + assert!( + !json.contains("from") && !json.contains("contract_version") && !json.contains("token"), + "fresh subscribe omits optional fields: {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)), + contract_version: Some(crate::CONTRACT_VERSION), + token: Some("read-token".into()), + }; + 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..1c0b8da --- /dev/null +++ b/crates/server/src/snapshot.rs @@ -0,0 +1,236 @@ +//! 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_projection::{AuditEntry, LapList, SignalTraceView}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::stream::Cursor; + +// 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 → Unofficial → Final`). +/// +/// 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 (revert/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 finished — but is not yet finalized. + /// Mirrors the canonical engine state `HeatState::Unofficial` (code-conventions §4). + Unofficial, + /// The result is finalized. + Final, +} + +/// 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 heat's marshaling audit trail (a [`AuditEntry`](gridfpv_projection::AuditEntry) list, #55). + MarshalingAudit, + /// A heat's captured RSSI signal trace (a [`SignalTraceView`](gridfpv_projection::SignalTraceView), + /// marshaling Slice 1). + SignalTrace, +} + +/// 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), + /// A heat's marshaling audit trail — the reverse-chronological "what changed, when, what + /// kind" the defensible-results panel renders (#55). Heat-scoped, fresh-value encoded. + MarshalingAudit(Vec), + /// A heat's captured per-competitor RSSI trace + enter/exit thresholds — the signal-as-evidence + /// surface (marshaling Slice 1). Heat-scoped; append-heavy, so delta-preferring like the lap list. + SignalTrace(SignalTraceView), +} + +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, + ProjectionBody::MarshalingAudit(_) => ProjectionKind::MarshalingAudit, + ProjectionBody::SignalTrace(_) => ProjectionKind::SignalTrace, + } + } +} + +/// 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(gridfpv_events::HeatId("q-1".into())), + phase: HeatPhase::Running, + ..Default::default() + }) + } + + #[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, + MarshalingAudit, + SignalTrace, + ] { + let json = serde_json::to_string(&kind).unwrap(); + let back: ProjectionKind = serde_json::from_str(&json).unwrap(); + assert_eq!(kind, back); + } + } + + #[test] + fn signal_trace_body_round_trips() { + let snap = Snapshot { + cursor: Cursor::new(3), + body: ProjectionBody::SignalTrace(SignalTraceView::default()), + }; + let json = serde_json::to_string(&snap).unwrap(); + let back: Snapshot = serde_json::from_str(&json).unwrap(); + assert_eq!(snap, back); + assert_eq!( + ProjectionBody::SignalTrace(SignalTraceView::default()).kind(), + ProjectionKind::SignalTrace + ); + } +} diff --git a/crates/server/src/stream.rs b/crates/server/src/stream.rs new file mode 100644 index 0000000..598f445 --- /dev/null +++ b/crates/server/src/stream.rs @@ -0,0 +1,194 @@ +//! 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::error::ProtocolError; +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 = "f64")]` so it renders as a plain TS +/// `number`. Our cursors/sequences are bounded well below 2^53, so `number` is exact +/// and avoids the wire/type mismatch a `u64` → wide-integer TS mapping would introduce. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[serde(transparent)] +#[ts(export, export_to = "bindings/", as = "f64")] +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, +} + +/// 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::*; + 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, + ..Default::default() + })), + }; + 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); + } + + #[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/timers.rs b/crates/server/src/timers.rs new file mode 100644 index 0000000..d5df6c6 --- /dev/null +++ b/crates/server/src/timers.rs @@ -0,0 +1,1039 @@ +//! Timers as **application-level configuration** — the `TimerRegistry` and `Timer` (issue #73). +//! +//! A timer is the thing that *produces lap-gate passes* — the built-in synthetic +//! **Mock**, or (reserved for #65/2b) a real **RotorHazard** server. The model parallels +//! the event model ([`EventRegistry`](crate::events::EventRegistry)): a Race Director configures +//! their timers **once** at the application level (a persisted registry) and each event simply +//! **selects** which of them to use (see [`EventMeta::timers`](crate::events::EventMeta::timers)). +//! Set up the RotorHazard once, and every new event just picks it. +//! +//! # Two pieces, mirroring events +//! +//! - **App-level registry (this module).** The [`TimerRegistry`] holds every configured +//! [`Timer`] behind a lock and **persists** them to `/timers.json` +//! (restored on boot; in-memory only when no data dir is configured). A built-in +//! **Mock** ([`MOCK_TIMER_ID`]) is always present — so an unconfigured Director can run a +//! sim race out of the box — and cannot be deleted. +//! - **Per-event selection (`crate::events`).** Each [`EventMeta`](crate::events::EventMeta) +//! carries a `timers: Vec` of the timers that event uses; new events (and Practice) +//! default to `["sim"]`. +//! +//! # The kinds +//! +//! [`TimerKind::Mock`] is the synthetic source wired end-to-end here (its `laps`/`lap_ms` drive +//! the per-event sim bridge). [`TimerKind::Rotorhazard`] is **config-only / reserved** — it +//! holds the RH server `url` so the surface and persistence are forward-compatible, but nothing +//! connects to it in this slice; that is 2b (#65). A selected RotorHazard timer is a no-op stub. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// The reserved id of the always-present built-in **Mock** timer. +/// +/// The Mock is seeded into every registry, draws its `laps`/`lap_ms` from the Director's +/// env defaults (`GRIDFPV_SIM_LAPS` / `GRIDFPV_SIM_LAP_MS`), and cannot be deleted — so a +/// Director with nothing configured can still run a sim race. New events default to selecting it. +pub const MOCK_TIMER_ID: &str = "mock"; + +/// The display name of the built-in Mock timer. +pub const MOCK_TIMER_NAME: &str = "Mock"; + +/// The default node/slot count for a timer that does not specify one (race redesign Slice 4a). +/// +/// Eight is the ubiquitous FPV timer width (RotorHazard's default, the Raceband R1–R8 grid), so a +/// timer persisted before the channel model existed — or created without an explicit `node_count` +/// — reads back as an 8-node timer, the same heat-size cap a real 8-seat timer enforces. +pub const DEFAULT_NODE_COUNT: u32 = 8; + +/// The file name (under the data dir) the timer registry is persisted to (issue #73). +pub const TIMERS_FILE: &str = "timers.json"; + +/// Identifies a **timer** in the application-level registry (issue #73). +/// +/// A transparent string newtype like [`EventId`](crate::scope::EventId): the built-in Mock +/// has the reserved id [`MOCK_TIMER_ID`]; created timers get an auto-generated slug + suffix id, +/// never user-entered (names are display-only). +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[serde(transparent)] +#[ts(export, export_to = "bindings/")] +pub struct TimerId(pub String); + +/// The kind of a timer — *how* it produces passes (issue #73). +/// +/// Externally tagged so it maps to a TS discriminated union. [`Mock`](TimerKind::Mock) is the +/// synthetic source wired end-to-end in this slice; [`Rotorhazard`](TimerKind::Rotorhazard) is +/// **reserved / config-only** — its `url` is stored and round-trips on the wire and on disk, but +/// nothing connects to it here (that is 2b / #65). A selected RotorHazard timer is a no-op stub. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum TimerKind { + /// The built-in synthetic source: emit a holeshot + `laps` laps per pilot at `lap_ms` + /// real-time pace when a heat goes Running (mirrors the existing sim knobs). + Mock { + /// Laps each sim pilot flies beyond the holeshot. + laps: u32, + /// The nominal real-time pace of one sim lap, in milliseconds. + #[ts(type = "number")] + lap_ms: u64, + }, + /// A **RotorHazard** server — config-only / reserved for 2b (#65). Holds the RH base URL the + /// connector will dial; not connected in this slice. + Rotorhazard { + /// The RotorHazard server base URL (e.g. `http://rotorhazard.local:5000`). + url: String, + }, +} + +/// What channels a timer can be tuned to (race redesign Slice 4a) — its *channel capability*, +/// declared generically (NOT RotorHazard-specific). +/// +/// A timer is one of two kinds: +/// +/// - [`Fixed`](ChannelCapability::Fixed) — the timer supports only a **specific allowed set** of +/// built-in catalog frequencies (raw MHz). A limited timer (e.g. a fixed-band module) exposes +/// only what it physically supports; a console must pick a heat's channels from this set. +/// - [`Flexible`](ChannelCapability::Flexible) — the timer accepts **any** frequency: the whole +/// standard catalog *plus* arbitrary custom raw MHz (e.g. RotorHazard, whose nodes tune freely). +/// +/// Externally tagged so it maps to a TS discriminated union. It is **additive** on the wire and on +/// disk: a timer persisted before the channel model existed deserializes with the +/// [`Default`] capability ([`Flexible`](ChannelCapability::Flexible)), so old `timers.json` files +/// round-trip and stay valid. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum ChannelCapability { + /// The timer supports only this explicit set of built-in catalog frequencies (raw MHz). A + /// console offers exactly these; assignment allocates from (the timer's available subset of) + /// these. + Fixed { + /// The allowed built-in channel centre frequencies, in raw MHz, in preference order. + channels: Vec, + }, + /// The timer accepts any frequency — the standard catalog plus arbitrary custom raw MHz. + Flexible, +} + +impl Default for ChannelCapability { + /// A timer with no declared capability is [`Flexible`](ChannelCapability::Flexible) — the + /// permissive default, so a pre-channel-model timer (and the free-tune Mock) stays usable. + fn default() -> Self { + ChannelCapability::Flexible + } +} + +impl ChannelCapability { + /// Whether `mhz` is a frequency this timer can be tuned to: any value for a + /// [`Flexible`](ChannelCapability::Flexible) timer, or one of the allowed set for a + /// [`Fixed`](ChannelCapability::Fixed) one. + pub fn allows(&self, mhz: u16) -> bool { + match self { + ChannelCapability::Flexible => true, + ChannelCapability::Fixed { channels } => channels.contains(&mhz), + } + } +} + +/// Whether a timer is currently usable, and — for a live source — the state of its connection +/// (issues #73, #65). +/// +/// Two **static** states describe a timer's resting config: the Mock is always +/// [`Ready`](TimerStatus::Ready) (it needs nothing external), and a configured-but-not-yet-dialed +/// RotorHazard timer reports [`Configured`](TimerStatus::Configured). The remaining four are +/// **dynamic** connection states the Director drives on a live (`live`-feature) RotorHazard timer +/// as its connection comes and goes: [`Connecting`](TimerStatus::Connecting) while the socket is +/// being established, [`Connected`](TimerStatus::Connected) once it is up, +/// [`Disconnected`](TimerStatus::Disconnected) when it drops, and [`Error`](TimerStatus::Error) +/// when the connection attempt fails. They are **additive on the wire** — a console that only knows +/// `Ready`/`Configured` still parses the type; new variants surface richer status. +/// +/// These dynamic states are **not persisted** (`timers.json` always restores a timer's resting +/// status from its kind — see [`Timer::status_for`]); they are live, in-memory, and reset to +/// `Configured` whenever the RH timer is reconfigured. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum TimerStatus { + /// Usable right now — the built-in Mock. + Ready, + /// Configured but not connected (a RotorHazard timer with a URL on file, not yet dialed). + Configured, + /// A live RotorHazard timer whose connection is being established. + Connecting, + /// A live RotorHazard timer with an established connection (passes are flowing in). + Connected, + /// A live RotorHazard timer whose connection has dropped (was up, now down). + Disconnected, + /// A live RotorHazard timer whose connection attempt failed (could not reach the server). + Error, +} + +/// Whether a connected RotorHazard timer carries the **GridFPV plugin** (RH plugin design D16, +/// Slice 1) — the in-process integration the Director probes for over the existing socket.io +/// connection (`gridfpv_hello` → `gridfpv_hello_ack`). Carried as an `Option` on [`Timer`]: +/// `None` is "not probed" (a Mock timer, or an RH not yet connected). Like [`TimerStatus`] it is a +/// **live, in-memory** value, not persisted (reset to `None` on load and on reconfigure) and +/// **additive on the wire** (`#[ts(optional)]` — an older console still parses a `Timer`). The +/// Director uses it to drive the required-with-guided-install UX (§5): `Missing` and `Incompatible` +/// surface the one-step install; `Present` surfaces a healthy timer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub enum PluginPresence { + /// Probed, but no GridFPV plugin answered the handshake (a stock RH, or one whose plugin + /// failed to load) — the guided-install prompt applies. An RH older than v4.3.0 also lands + /// here (it can't run the plugin), and the install guidance says to update RH first. + Missing, + /// The plugin answered and its `gridfpv_*` protocol is compatible with this Director. + Present { + /// The plugin build version (e.g. `"0.1.0"`). + plugin_version: String, + /// The RHAPI version the plugin reported (e.g. `"1.4"`). + rhapi_version: String, + /// The capabilities the plugin declared (e.g. `["hello"]`). + capabilities: Vec, + }, + /// The plugin answered but its protocol version is outside the Director's supported range — + /// the guided install offers the matching plugin build. + Incompatible { + /// The plugin build version, so the UI can name the mismatch. + plugin_version: String, + /// The plugin's `gridfpv_*` protocol version (the field that didn't match). + protocol_version: u32, + /// A short plain-language reason for the mismatch. + reason: String, + }, +} + +/// One configured timer in the application-level registry (issue #73). +/// +/// The wire shape `GET /timers` returns and the on-disk shape `timers.json` persists: a stable +/// [`TimerId`], a human display `name`, the [`TimerKind`] (its config), and a derived +/// [`TimerStatus`]. Derives serde (its JSON *is* both the wire and the persisted form) and +/// `ts_rs::TS` so the frontend reads a generated `Timer` type. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct Timer { + /// The stable handle an event selects by and the API addresses (`PUT /timers/{id}`). + pub id: TimerId, + /// The human-readable display name (display-only; the id is authoritative). + pub name: String, + /// The kind + config: a [`TimerKind::Mock`] or a reserved [`TimerKind::Rotorhazard`]. + pub kind: TimerKind, + /// The derived usability of the timer (see [`TimerStatus`]). + pub status: TimerStatus, + /// The timer's **channel capability** (race redesign Slice 4a): the set of frequencies it can + /// tune to ([`Fixed`](ChannelCapability::Fixed)) or that it tunes freely + /// ([`Flexible`](ChannelCapability::Flexible)). Additive — defaults to + /// [`Flexible`](ChannelCapability::Flexible) for a pre-channel-model timer. + #[serde(default)] + pub channel_capability: ChannelCapability, + /// How many nodes/slots the timer has (race redesign Slice 4a) — the **heat-size cap**: a + /// heat's lineup must be ≤ this. Additive; defaults to [`DEFAULT_NODE_COUNT`]. + #[serde(default = "default_node_count")] + pub node_count: u32, + /// The timer's **defined available channels** (race redesign Slice 4a): the raw-MHz channels, + /// within its [`channel_capability`](Timer::channel_capability), that the Race Director has + /// made available on this timer — the pool per-heat assignment allocates from, in preference + /// order. Empty means none configured (assignment then allocates nothing). Additive — defaults + /// empty for a pre-channel-model timer. + #[serde(default)] + pub available_channels: Vec, + /// Whether this (RotorHazard) timer carries the **GridFPV plugin** (D16, S1). `None` until + /// probed (Mock timers, or an RH not yet connected). Live, in-memory, not persisted — reset to + /// `None` on load/reconfigure and driven by the connect-time handshake probe. Additive on the + /// wire (`#[ts(optional)]`): an older console (or a test fixture) omits it. + #[serde(default)] + #[ts(optional)] + pub plugin: Option, +} + +/// The `serde(default)` provider for [`Timer::node_count`] (a function because serde defaults must +/// be callable): the ubiquitous 8-node width. +fn default_node_count() -> u32 { + DEFAULT_NODE_COUNT +} + +impl Timer { + /// Derive the [`TimerStatus`] from a [`TimerKind`]: the Mock is [`Ready`](TimerStatus::Ready); + /// a reserved RotorHazard timer is [`Configured`](TimerStatus::Configured) (not yet connected). + fn status_for(kind: &TimerKind) -> TimerStatus { + match kind { + TimerKind::Mock { .. } => TimerStatus::Ready, + TimerKind::Rotorhazard { .. } => TimerStatus::Configured, + } + } +} + +/// The body of `POST /timers` — the config a caller supplies to create a timer (issue #73). +/// +/// A display `name` plus the [`TimerKind`]; the **id is auto-generated** server-side (a slug of +/// the name + a short random suffix), never user-entered, mirroring `POST /events`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct CreateTimerRequest { + /// The display name for the new timer. + pub name: String, + /// The kind + config of the new timer. + pub kind: TimerKind, + /// The new timer's **channel capability** (race redesign Slice 4a). Optional and additive — + /// omit it for the permissive [`Flexible`](ChannelCapability::Flexible) default. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub channel_capability: Option, + /// The new timer's **node/slot count** (race redesign Slice 4a) — the heat-size cap. Optional; + /// defaults to [`DEFAULT_NODE_COUNT`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub node_count: Option, + /// The new timer's **available channels** in raw MHz (race redesign Slice 4a). Optional; + /// defaults to empty (none configured). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub available_channels: Option>, +} + +/// The body of `PUT /timers/{id}` — the editable fields of a timer (issue #73). +/// +/// Edits the display `name` and/or the [`TimerKind`] config (e.g. retune the sim's `lap_ms`, or +/// point a RotorHazard timer at a new URL). Both optional so a partial edit is a one-field body; +/// the id is fixed (it is in the path) and the built-in Mock may be retuned but not removed. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct UpdateTimerRequest { + /// A new display name, or `None` to leave it unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub name: Option, + /// A new kind + config, or `None` to leave it unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub kind: Option, + /// A new **channel capability** (race redesign Slice 4a), or `None` to leave it unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub channel_capability: Option, + /// A new **node/slot count** (race redesign Slice 4a), or `None` to leave it unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub node_count: Option, + /// A new **available-channels** set in raw MHz (race redesign Slice 4a), or `None` to leave it + /// unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub available_channels: Option>, +} + +/// The body of `PUT /events/{id}/timers` — the timer ids an event selects (issue #73), and +/// optionally which of them is the **primary** (issue #112). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct SetEventTimersRequest { + /// The timers this event uses, in selection order. Each must name a known timer. + pub ids: Vec, + /// The **primary** timer among `ids` (issue #112): the timer whose passes feed the log while + /// healthy, the rest being hot-standby alternates. Optional and additive — omit it to leave the + /// primary defaulting to the first selected timer. When given, it must be one of `ids`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub primary: Option, +} + +/// The body of `PUT /events/{id}/primary-timer` — designate which selected timer is the +/// **primary** (issue #112), the rest being alternates. The `id` must be one of the event's +/// currently-selected timers; `null` clears the override (the first selected timer becomes primary). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings/")] +pub struct SetPrimaryTimerRequest { + /// The timer to make primary, or `null` to clear the override (default to the first selected). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub id: Option, +} + +/// The application-level registry of all configured timers (issue #73). +/// +/// Maps each [`TimerId`] to its [`Timer`]. A built-in **Mock** ([`MOCK_TIMER_ID`]) is always +/// present. The set is **persisted** to `/timers.json` (restored on boot) so the RD's +/// timers survive a Director restart; with no data dir configured it is in-memory only. Cloning +/// shares the one registry (`Arc>`), so it is the axum router state cloned into every +/// handler, exactly like the [`EventRegistry`](crate::events::EventRegistry). +#[derive(Clone)] +pub struct TimerRegistry { + inner: Arc>, +} + +/// The guarded interior: the timer map and where `timers.json` lives. +struct Registry { + /// `TimerId → Timer`. A `BTreeMap` so listing is deterministic (the Mock is listed + /// first explicitly regardless). + timers: BTreeMap, + /// Directory `timers.json` is persisted under; `None` ⇒ in-memory only (no data dir). + data_dir: Option, +} + +impl TimerRegistry { + /// Build a registry seeded with the built-in Mock, persisting to `data_dir` when given. + /// + /// The Mock's `laps`/`lap_ms` default from `sim_laps`/`sim_lap_ms` (the Director passes + /// the env defaults). When `data_dir` is `Some` and a `timers.json` already exists, the saved + /// timers are restored over the top (an unreadable/corrupt file degrades to just the + /// Mock rather than failing to boot); a restored Mock's config wins so a retune + /// survives a restart. When `data_dir` is `None` the registry is in-memory only. + pub fn new( + data_dir: Option, + sim_laps: u32, + sim_lap_ms: u64, + ) -> Result { + let mut timers = BTreeMap::new(); + + // Always seed the built-in Mock first. Sensible channel defaults (race redesign Slice 4a): + // flexible, 8 nodes, seeded from Raceband R1–R8 — so an out-of-the-box sim race has an + // 8-channel pool and an 8-seat cap, matching a typical real timer. + let sim = Timer { + id: TimerId(MOCK_TIMER_ID.to_string()), + name: MOCK_TIMER_NAME.to_string(), + kind: TimerKind::Mock { + laps: sim_laps, + lap_ms: sim_lap_ms, + }, + status: TimerStatus::Ready, + channel_capability: ChannelCapability::Flexible, + node_count: DEFAULT_NODE_COUNT, + available_channels: crate::channels::RACEBAND_MHZ.to_vec(), + plugin: None, + }; + timers.insert(sim.id.clone(), sim); + + if let Some(dir) = &data_dir { + std::fs::create_dir_all(dir).map_err(|e| { + TimerError(format!("could not create data dir {}: {e}", dir.display())) + })?; + // Restore persisted timers over the seed (a missing/corrupt file is ignored — the + // Director still boots with at least the Mock). + if let Some(restored) = read_persisted_timers(dir) { + for mut timer in restored { + // Keep the derived status authoritative (never trust a persisted status), and + // reset the live plugin-presence — it is re-probed on connect, never restored. + timer.status = Timer::status_for(&timer.kind); + timer.plugin = None; + timers.insert(timer.id.clone(), timer); + } + } + } + + Ok(Self { + inner: Arc::new(RwLock::new(Registry { timers, data_dir })), + }) + } + + /// Every timer, **Mock first**, then the rest in id order — the `GET /timers` body. + pub fn list(&self) -> Vec { + let reg = self.read(); + let mut out = Vec::with_capacity(reg.timers.len()); + let sim = TimerId(MOCK_TIMER_ID.to_string()); + if let Some(s) = reg.timers.get(&sim) { + out.push(s.clone()); + } + for (id, timer) in ®.timers { + if *id != sim { + out.push(timer.clone()); + } + } + out + } + + /// Whether a timer with `id` exists — the per-event selection validates each id through this. + pub fn exists(&self, id: &TimerId) -> bool { + self.read().timers.contains_key(id) + } + + /// The [`Timer`] for `id`, or `None` — the source bridge resolves a selected id's config here. + pub fn get(&self, id: &TimerId) -> Option { + self.read().timers.get(id).cloned() + } + + /// Create a timer from a [`CreateTimerRequest`], returning it (issue #73). + /// + /// The **id is auto-generated** — a slug of the `name` + a short random suffix — so it is + /// unique and never the reserved `sim`. The derived [`TimerStatus`] is set from the kind, and + /// the registry is **persisted** on success. + pub fn create(&self, request: &CreateTimerRequest) -> Result { + let mut reg = self.write(); + let id = loop { + let candidate = TimerId(format!("{}-{}", slugify(&request.name), short_suffix())); + if candidate.0 != MOCK_TIMER_ID && !reg.timers.contains_key(&candidate) { + break candidate; + } + }; + let timer = Timer { + id: id.clone(), + name: request.name.trim().to_string(), + status: Timer::status_for(&request.kind), + kind: request.kind.clone(), + channel_capability: request.channel_capability.clone().unwrap_or_default(), + node_count: request.node_count.unwrap_or(DEFAULT_NODE_COUNT), + available_channels: request.available_channels.clone().unwrap_or_default(), + plugin: None, + }; + reg.timers.insert(id, timer.clone()); + reg.persist()?; + Ok(timer) + } + + /// Edit a timer's name and/or kind (issue #73), returning the updated [`Timer`]. + /// + /// The built-in Mock may be retuned (e.g. a new `lap_ms`) but not renamed away — any + /// timer's name/kind is editable. An unknown id is a [`TimerError`]. The registry is + /// **persisted** on success. + pub fn update(&self, id: &TimerId, request: &UpdateTimerRequest) -> Result { + let mut reg = self.write(); + let timer = reg + .timers + .get_mut(id) + .ok_or_else(|| TimerError(format!("no timer with id {:?}", id.0)))?; + if let Some(name) = &request.name { + let trimmed = name.trim(); + if !trimmed.is_empty() { + timer.name = trimmed.to_string(); + } + } + if let Some(kind) = &request.kind { + timer.kind = kind.clone(); + timer.status = Timer::status_for(kind); + // A reconfigured timer (new URL/kind) must be re-probed: drop any stale plugin state. + timer.plugin = None; + } + if let Some(capability) = &request.channel_capability { + timer.channel_capability = capability.clone(); + } + if let Some(node_count) = request.node_count { + timer.node_count = node_count; + } + if let Some(available) = &request.available_channels { + timer.available_channels = available.clone(); + } + let updated = timer.clone(); + reg.persist()?; + Ok(updated) + } + + /// Set a timer's **live connection status** (issues #73, #65) — the Director drives an RH + /// timer's [`TimerStatus`] as its connection comes and goes (connecting → connected → + /// disconnected/error). A no-op for an unknown id. This is an **in-memory only** update: the + /// dynamic states are not persisted (a `persist` always re-derives the resting status from the + /// kind), so a restart starts a configured RH timer back at [`Configured`](TimerStatus::Configured). + pub fn set_status(&self, id: &TimerId, status: TimerStatus) { + let mut reg = self.write(); + if let Some(timer) = reg.timers.get_mut(id) { + timer.status = status; + } + } + + /// Set a timer's **live GridFPV-plugin presence** (D16, S1) — the Director drives this from the + /// connect-time `gridfpv_hello` handshake (present/compatible, missing, or incompatible). A + /// no-op for an unknown id. **In-memory only**, like [`set_status`](Self::set_status): it is + /// re-probed on every (re)connect and never persisted. + pub fn set_plugin(&self, id: &TimerId, plugin: PluginPresence) { + let mut reg = self.write(); + if let Some(timer) = reg.timers.get_mut(id) { + timer.plugin = Some(plugin); + } + } + + /// Delete a timer (issue #73). The built-in **Mock cannot be deleted** (it is always + /// present); attempting to is a [`TimerError`]. An unknown id is also an error. The registry + /// is **persisted** on success. + pub fn delete(&self, id: &TimerId) -> Result<(), TimerError> { + if id.0 == MOCK_TIMER_ID { + return Err(TimerError( + "the built-in Mock timer cannot be deleted".to_string(), + )); + } + let mut reg = self.write(); + if reg.timers.remove(id).is_none() { + return Err(TimerError(format!("no timer with id {:?}", id.0))); + } + reg.persist()?; + Ok(()) + } + + fn read(&self) -> std::sync::RwLockReadGuard<'_, Registry> { + self.inner.read().expect("timer registry lock poisoned") + } + + fn write(&self) -> std::sync::RwLockWriteGuard<'_, Registry> { + self.inner.write().expect("timer registry lock poisoned") + } +} + +impl Registry { + /// Persist the timer set to `/timers.json` (issue #73), a no-op with no data dir. + /// The Mock is persisted too so a retune survives a restart. + fn persist(&self) -> Result<(), TimerError> { + let Some(dir) = &self.data_dir else { + return Ok(()); + }; + let timers: Vec<&Timer> = self.timers.values().collect(); + let json = serde_json::to_string_pretty(&timers) + .map_err(|e| TimerError(format!("could not serialize timers: {e}")))?; + std::fs::write(timers_path(dir), json) + .map_err(|e| TimerError(format!("could not persist timers: {e}"))) + } +} + +/// The file the timer set is persisted to under `dir`: `/timers.json`. +fn timers_path(dir: &Path) -> PathBuf { + dir.join(TIMERS_FILE) +} + +/// Read the persisted timers from `/timers.json`, or `None` if absent/unreadable/corrupt. +/// A bad file degrades to "no persisted timers" so the Director still boots with the Mock. +fn read_persisted_timers(dir: &Path) -> Option> { + let raw = std::fs::read_to_string(timers_path(dir)).ok()?; + serde_json::from_str(&raw).ok() +} + +/// The largest Mock `laps` count we accept (release-hardening P2): a sane ceiling so a fat-fingered +/// or hostile value can't ask the sim to generate a runaway number of laps per pilot. +pub const MAX_MOCK_LAPS: u32 = 1000; + +/// Validate a timer's effective configuration (release-hardening P2), returning a human-readable +/// message on the first problem (the caller maps it to a `400`). +/// +/// Rejects a `node_count` of `0` (it caps every heat to **no** pilots — nothing could ever race), +/// an empty/whitespace RotorHazard URL (nothing to dial), and a Mock `laps` count beyond +/// [`MAX_MOCK_LAPS`] (a runaway sim). `node_count` is passed in so the merged value can be checked +/// on a partial edit. +pub fn validate_timer_config(kind: &TimerKind, node_count: u32) -> Result<(), String> { + if node_count == 0 { + return Err( + "node_count must be at least 1 (a 0-node timer caps every heat to no pilots)" + .to_string(), + ); + } + match kind { + TimerKind::Rotorhazard { url } if url.trim().is_empty() => { + Err("a RotorHazard timer requires a non-empty server URL".to_string()) + } + TimerKind::Mock { laps, .. } if *laps > MAX_MOCK_LAPS => { + Err(format!("laps must be at most {MAX_MOCK_LAPS}")) + } + _ => Ok(()), + } +} + +/// An error mutating the timer registry (a persistence failure, an unknown id, a protected delete). +#[derive(Debug, Clone)] +pub struct TimerError(pub String); + +impl std::fmt::Display for TimerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "timer registry error: {}", self.0) + } +} + +impl std::error::Error for TimerError {} + +/// Slugify a display name into an id-friendly stem (same rule as the event registry): lowercase +/// ASCII alphanumerics kept, every other run collapsed to a single `-`, trimmed of dashes; an +/// empty/symbol-only name yields `timer`. +fn slugify(name: &str) -> String { + let mut slug = String::new(); + let mut prev_dash = false; + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + prev_dash = false; + } else if !prev_dash { + slug.push('-'); + prev_dash = true; + } + } + let trimmed = slug.trim_matches('-'); + if trimmed.is_empty() { + "timer".to_string() + } else { + trimmed.to_string() + } +} + +/// A short random lowercase-alphanumeric suffix making an auto-generated id unique (same source +/// as the event registry — the OS CSPRNG). +fn short_suffix() -> String { + const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + let mut bytes = [0u8; 6]; + getrandom::fill(&mut bytes).expect("OS CSPRNG available"); + bytes + .iter() + .map(|b| ALPHABET[(*b as usize) % ALPHABET.len()] as char) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_timer_config_rejects_bad_configs() { + // A 0-node timer caps every heat to no pilots — rejected (P2). + assert!( + validate_timer_config( + &TimerKind::Mock { + laps: 5, + lap_ms: 100 + }, + 0 + ) + .is_err() + ); + // An empty / whitespace RotorHazard URL can never be dialed — rejected. + assert!(validate_timer_config(&TimerKind::Rotorhazard { url: " ".into() }, 8).is_err()); + // A runaway Mock laps count is rejected. + assert!( + validate_timer_config( + &TimerKind::Mock { + laps: MAX_MOCK_LAPS + 1, + lap_ms: 100 + }, + 8 + ) + .is_err() + ); + // A sane config passes. + assert!( + validate_timer_config( + &TimerKind::Mock { + laps: 5, + lap_ms: 2500 + }, + 8 + ) + .is_ok() + ); + assert!( + validate_timer_config( + &TimerKind::Rotorhazard { + url: "http://rh.local:5000".into() + }, + 8 + ) + .is_ok() + ); + } + + fn sim_req(name: &str) -> CreateTimerRequest { + CreateTimerRequest { + name: name.to_string(), + kind: TimerKind::Mock { + laps: 3, + lap_ms: 2000, + }, + channel_capability: None, + node_count: None, + available_channels: None, + } + } + + fn rh_req(name: &str, url: &str) -> CreateTimerRequest { + CreateTimerRequest { + name: name.to_string(), + kind: TimerKind::Rotorhazard { + url: url.to_string(), + }, + channel_capability: None, + node_count: None, + available_channels: None, + } + } + + #[test] + fn mock_is_always_present_and_first() { + let reg = TimerRegistry::new(None, 5, 2500).unwrap(); + let list = reg.list(); + let first = list.first().unwrap(); + assert_eq!(first.id.0, MOCK_TIMER_ID); + assert_eq!(first.name, MOCK_TIMER_NAME); + assert_eq!(first.status, TimerStatus::Ready); + // The Mock draws its config from the Director's env defaults. + assert_eq!( + first.kind, + TimerKind::Mock { + laps: 5, + lap_ms: 2500 + } + ); + } + + #[test] + fn create_auto_generates_a_unique_slug_id_and_lists_after_mock() { + let reg = TimerRegistry::new(None, 5, 2500).unwrap(); + let a = reg + .create(&rh_req("Track RH!", "http://rh.local:5000")) + .unwrap(); + let b = reg + .create(&rh_req("Track RH!", "http://rh.local:5000")) + .unwrap(); + assert!(a.id.0.starts_with("track-rh-")); + assert_ne!(a.id, b.id); + assert_eq!(a.status, TimerStatus::Configured); + let ids: Vec<_> = reg.list().into_iter().map(|t| t.id).collect(); + assert_eq!(ids[0].0, MOCK_TIMER_ID); + assert!(ids.contains(&a.id) && ids.contains(&b.id)); + } + + #[test] + fn update_edits_name_and_kind() { + let reg = TimerRegistry::new(None, 5, 2500).unwrap(); + let created = reg.create(&sim_req("My Sim")).unwrap(); + let updated = reg + .update( + &created.id, + &UpdateTimerRequest { + name: Some("Renamed".into()), + kind: Some(TimerKind::Mock { + laps: 9, + lap_ms: 1000, + }), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(updated.name, "Renamed"); + assert_eq!( + updated.kind, + TimerKind::Mock { + laps: 9, + lap_ms: 1000 + } + ); + } + + #[test] + fn retuning_the_mock_is_allowed_but_deleting_it_is_not() { + let reg = TimerRegistry::new(None, 5, 2500).unwrap(); + let sim = TimerId(MOCK_TIMER_ID.into()); + // Retune is fine. + reg.update( + &sim, + &UpdateTimerRequest { + name: None, + kind: Some(TimerKind::Mock { + laps: 1, + lap_ms: 50, + }), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + reg.get(&sim).unwrap().kind, + TimerKind::Mock { + laps: 1, + lap_ms: 50 + } + ); + // Delete is rejected. + assert!(reg.delete(&sim).is_err()); + assert!(reg.exists(&sim)); + } + + #[test] + fn delete_removes_a_created_timer() { + let reg = TimerRegistry::new(None, 5, 2500).unwrap(); + let created = reg.create(&sim_req("Temp")).unwrap(); + assert!(reg.exists(&created.id)); + reg.delete(&created.id).unwrap(); + assert!(!reg.exists(&created.id)); + assert!(reg.delete(&created.id).is_err()); + } + + #[test] + fn timers_persist_across_a_restart_with_a_data_dir() { + let dir = std::env::temp_dir().join(format!("gridfpv-timers-test-{}", short_suffix())); + { + let reg = TimerRegistry::new(Some(dir.clone()), 5, 2500).unwrap(); + let created = reg + .create(&rh_req("Field RH", "http://rh.local:5000")) + .unwrap(); + // Retune the Mock too, to prove its config also survives. + reg.update( + &TimerId(MOCK_TIMER_ID.into()), + &UpdateTimerRequest { + name: None, + kind: Some(TimerKind::Mock { + laps: 7, + lap_ms: 1234, + }), + ..Default::default() + }, + ) + .unwrap(); + + let reopened = TimerRegistry::new(Some(dir.clone()), 5, 2500).unwrap(); + // The created RH timer survived… + let got = reopened.get(&created.id).unwrap(); + assert_eq!( + got.kind, + TimerKind::Rotorhazard { + url: "http://rh.local:5000".into() + } + ); + assert_eq!(got.status, TimerStatus::Configured); + // …and so did the retuned Mock config. + assert_eq!( + reopened.get(&TimerId(MOCK_TIMER_ID.into())).unwrap().kind, + TimerKind::Mock { + laps: 7, + lap_ms: 1234 + } + ); + } + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn set_status_drives_an_rh_timers_live_connection_state() { + // The Director publishes an RH timer's connection lifecycle through `set_status` (#65): + // Configured (resting) → Connecting → Connected → Disconnected, all live in `GET /timers`. + let reg = TimerRegistry::new(None, 5, 2500).unwrap(); + let rh = reg + .create(&rh_req("Field RH", "http://rh.local:5000")) + .unwrap(); + assert_eq!(reg.get(&rh.id).unwrap().status, TimerStatus::Configured); + + for status in [ + TimerStatus::Connecting, + TimerStatus::Connected, + TimerStatus::Disconnected, + TimerStatus::Error, + ] { + reg.set_status(&rh.id, status); + assert_eq!(reg.get(&rh.id).unwrap().status, status); + // The live status is reflected in the `GET /timers` listing too. + let listed = reg.list().into_iter().find(|t| t.id == rh.id).unwrap(); + assert_eq!(listed.status, status); + } + + // An unknown id is a no-op (no panic). + reg.set_status(&TimerId("nope".into()), TimerStatus::Connected); + } + + #[test] + fn live_status_is_not_persisted_and_resets_to_configured_on_reopen() { + // Dynamic connection states are in-memory only: a reopen restores the RH timer at its + // resting `Configured`, never a stale `Connected`/`Error`. + let dir = std::env::temp_dir().join(format!("gridfpv-timers-status-{}", short_suffix())); + { + let reg = TimerRegistry::new(Some(dir.clone()), 5, 2500).unwrap(); + let rh = reg + .create(&rh_req("Field RH", "http://rh.local:5000")) + .unwrap(); + reg.set_status(&rh.id, TimerStatus::Connected); + assert_eq!(reg.get(&rh.id).unwrap().status, TimerStatus::Connected); + + let reopened = TimerRegistry::new(Some(dir.clone()), 5, 2500).unwrap(); + assert_eq!( + reopened.get(&rh.id).unwrap().status, + TimerStatus::Configured, + "a restored RH timer rests at Configured, not a persisted live state" + ); + } + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn the_seeded_mock_has_channel_defaults() { + // Race redesign Slice 4a: the built-in Mock is flexible, 8 nodes, seeded from Raceband. + let reg = TimerRegistry::new(None, 5, 2500).unwrap(); + let mock = reg.get(&TimerId(MOCK_TIMER_ID.into())).unwrap(); + assert_eq!(mock.channel_capability, ChannelCapability::Flexible); + assert_eq!(mock.node_count, DEFAULT_NODE_COUNT); + assert_eq!( + mock.available_channels, + crate::channels::RACEBAND_MHZ.to_vec() + ); + } + + #[test] + fn channel_capability_node_count_and_available_persist_across_restart() { + // Race redesign Slice 4a: a timer's Fixed capability + node count + available channels + // survive a Director restart, and an old `timers.json` (no channel fields) reads back valid. + let dir = std::env::temp_dir().join(format!("gridfpv-timers-chan-{}", short_suffix())); + let fixed = ChannelCapability::Fixed { + channels: vec![5658, 5695, 5732, 5769], + }; + { + let reg = TimerRegistry::new(Some(dir.clone()), 5, 2500).unwrap(); + let created = reg + .create(&CreateTimerRequest { + name: "Field RH".into(), + kind: TimerKind::Rotorhazard { + url: "http://rh.local:5000".into(), + }, + channel_capability: Some(fixed.clone()), + node_count: Some(4), + available_channels: Some(vec![5658, 5695, 5732, 5769]), + }) + .unwrap(); + assert_eq!(created.channel_capability, fixed); + assert_eq!(created.node_count, 4); + + let reopened = TimerRegistry::new(Some(dir.clone()), 5, 2500).unwrap(); + let got = reopened.get(&created.id).unwrap(); + assert_eq!(got.channel_capability, fixed); + assert_eq!(got.node_count, 4); + assert_eq!(got.available_channels, vec![5658, 5695, 5732, 5769]); + } + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_pre_channel_model_timers_file_deserializes_with_defaults() { + // An old `timers.json` written before the channel fields existed must still load — the new + // fields default (Flexible, 8 nodes, no available channels). + let dir = std::env::temp_dir().join(format!("gridfpv-timers-legacy-{}", short_suffix())); + std::fs::create_dir_all(&dir).unwrap(); + // A minimal legacy timer entry: id/name/kind/status only. + let legacy = r#"[{"id":"mock","name":"Mock","kind":{"Mock":{"laps":3,"lap_ms":2000}},"status":"Ready"}, + {"id":"old-rh","name":"Old RH","kind":{"Rotorhazard":{"url":"http://x:5000"}},"status":"Configured"}]"#; + std::fs::write(timers_path(&dir), legacy).unwrap(); + let reg = TimerRegistry::new(Some(dir.clone()), 5, 2500).unwrap(); + let old = reg.get(&TimerId("old-rh".into())).unwrap(); + assert_eq!(old.channel_capability, ChannelCapability::Flexible); + assert_eq!(old.node_count, DEFAULT_NODE_COUNT); + assert!(old.available_channels.is_empty()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn update_edits_channel_capability_and_node_count() { + let reg = TimerRegistry::new(None, 5, 2500).unwrap(); + let created = reg.create(&sim_req("Tunable")).unwrap(); + let updated = reg + .update( + &created.id, + &UpdateTimerRequest { + node_count: Some(6), + available_channels: Some(vec![5800, 5820, 5840]), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(updated.node_count, 6); + assert_eq!(updated.available_channels, vec![5800, 5820, 5840]); + } + + #[test] + fn a_corrupt_timers_file_degrades_to_just_the_mock() { + let dir = std::env::temp_dir().join(format!("gridfpv-timers-bad-{}", short_suffix())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(timers_path(&dir), b"not json at all").unwrap(); + let reg = TimerRegistry::new(Some(dir.clone()), 5, 2500).unwrap(); + let list = reg.list(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].id.0, MOCK_TIMER_ID); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/server/src/ws.rs b/crates/server/src/ws.rs new file mode 100644 index 0000000..64e140f --- /dev/null +++ b/crates/server/src/ws.rs @@ -0,0 +1,688 @@ +//! 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::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{Path, State}; +use axum::response::{IntoResponse, Response}; +use gridfpv_events::{CompetitorRef, Event}; +use gridfpv_projection::{LapList, lap_list_marshaled}; +use gridfpv_storage::StoredEvent; + +use crate::app::{AppState, resolve_event}; +use crate::error::{ErrorCode, ProtocolError}; +use crate::events::EventRegistry; +use crate::live_state::{live_state, live_state_over, with_heat_timing}; +use crate::scope::{EventId, 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 { + // The signal trace appends incrementally as a heat runs (chunk after chunk), so it is + // delta-preferring like the lap list and live state (#59 will encode the appended chunks). + ProjectionKind::LiveRaceState + | ProjectionKind::LapList + | ProjectionKind::SignalTrace => Encoding::DeltaPreferring, + ProjectionKind::HeatResult + | ProjectionKind::Ranking + | ProjectionKind::EventOutcome + // The marshaling audit re-folds wholesale on each correction (it is short — one entry + // per ruling), so a delta would be no smaller; serve it fresh (#55). + | ProjectionKind::MarshalingAudit => 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. + /// + /// `overlay` is the event's open-practice accumulator (open-practice format, Slice 1). An + /// open-practice heat's laps are accumulated in memory (NOT logged), so the log fold can't see + /// them; the live-state phase/clock are always the **real log's** (folded here exactly as for any + /// heat), and [`OpenPracticeLive::merge_into`](crate::open_practice::OpenPracticeLive::merge_into) + /// then splices the accumulator's per-channel laps onto that log-authoritative base — for a Heat + /// scope only when it addresses the active open-practice heat. The lap-list (pilot) scope is + /// unaffected (open practice is per channel, not per pilot). + fn fold( + scope: &Scope, + stored: &[StoredEvent], + overlay: Option<&crate::open_practice::OpenPracticeLive>, + ) -> Option { + // The bare-event view the lap/phase fold consumes; the live-state clock timing is + // folded separately from `stored` (which carries the `recorded_at` server timestamps). + let events: Vec = stored.iter().map(|s| s.event.clone()).collect(); + let events = events.as_slice(); + match scope { + Scope::Event { .. } => { + // Phase/clock are the log's; the open-practice accumulator only splices its + // non-logged per-channel laps onto that base (a no-op when no op heat is active). + // `with_heat_timing` anchors the clock to the current heat's race-go (#62 follow-up). + let mut live = with_heat_timing(live_state(events), stored); + if let Some(op) = overlay { + live = op.merge_into(live); + } + Some(ProjectionBody::LiveRaceState(live)) + } + Scope::Class { class, .. } => { + // The class's REAL filtered window, with preserved global offsets — the same + // fold as `snapshot_class`, so snapshot and stream converge (they used to + // diverge: the stream folded the whole event). Offsets matter so marshaling + // adjudications (global LogRef targets) resolve inside the filtered view. + let window = crate::app::class_window_offsets(events, class); + let mut live = with_heat_timing(live_state_over(&window), stored); + if let Some(op) = overlay { + live = op.merge_into(live); + } + Some(ProjectionBody::LiveRaceState(live)) + } + 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; + } + // The heat's phase/clock are its real log window; the race-go timing folds from the + // full stored log. Splice the open-practice laps on when this Heat scope addresses + // the active open-practice heat. + let window = crate::app::heat_window_offsets(events, heat); + let mut live = with_heat_timing(live_state_over(&window), stored); + if let Some(op) = overlay { + if op.active_heat().as_ref() == Some(heat) { + live = op.merge_into(live); + } + } + Some(ProjectionBody::LiveRaceState(live)) + } + 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 /events/{event_id}/stream` — upgrade to the change-stream WebSocket +/// (protocol.html §3, §9.1), scoped to one event's log (issue #72). +/// +/// The handler resolves `event_id` to that event's [`AppState`] through the +/// [`EventRegistry`] (an unknown id → a typed 404 *before* the upgrade), then upgrades the +/// connection and hands it to [`run_stream`] against THAT log; auth (#44) and control +/// command handling (#45) layer on separately — this is the read stream only. +pub async fn stream_handler( + ws: WebSocketUpgrade, + State(registry): State, + Path(event_id): Path, +) -> Response { + let state = match resolve_event(®istry, &event_id) { + Ok(state) => state, + // An unknown event id can't open a stream — reject the upgrade with the typed 404. + Err(err) => return err.into_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 — it doubles as the connect message (§7), + // carrying the contract version and an optional read token. + let request = match recv_subscribe(&mut socket).await { + Ok(req) => req, + Err(err) => { + close_with(&mut socket, err).await; + return; + } + }; + + // 1a. Contract-version negotiation (#46, protocol.html §7, §9.7). The client states the + // version it was built against; if it falls outside the server's supported band we + // send the "too old / too new, please refresh" signal and close before streaming. + // An absent version means "unspecified" — treated as this build's own version. + let client_version = request.contract_version.unwrap_or(crate::CONTRACT_VERSION); + if !crate::is_supported_contract_version(client_version) { + close_with( + &mut socket, + crate::ServerHello::refresh_error(client_version), + ) + .await; + return; + } + + // 1b. Read auth (#44, protocol.html §5). Reads are open on the LAN, so an absent token + // is fine; a *present* token must be valid (a stale join-token is rejected rather + // than silently downgraded to anonymous). + if let Err(err) = state.tokens().authenticate_read(request.token.as_deref()) { + 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 (with `recorded_at`, for the race-clock timing) and fold any + // new events into envelopes. + let events = match state.read_stored() { + Ok((events, _)) => events, + Err(err) => { + close_with(&mut socket, err).await; + return; + } + }; + // The event's open-practice accumulator (open-practice format, Slice 1): the per-channel, + // in-memory (NOT logged) laps. The fold serves the **log's** phase/clock and splices these + // laps on top so they drive the stream without the phase/clock ever drifting from the log. + // `wake_streams` after a pass / clear is what re-enters this loop. + let overlay = state.open_practice(); + for message in engine.advance(&events, Some(&overlay)) { + 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. + /// + /// `overlay` is the event's open-practice accumulator (open-practice format, Slice 1). The + /// per-offset walk folds the **pure log** (no laps overlay) so logged changes — including every + /// real heat-state transition (phase/clock) — stay gap-free; then, when an open-practice heat is + /// active, a final laps-spliced fold of the current prefix is emitted if it differs — that is the + /// non-logged per-channel live re-snapshot. Each `wake_streams` after a pass / clear re-enters + /// this with a fresh `overlay`, so a clear settles back onto the bare log state with no stale + /// frame. + /// + /// # Scheduling a heat wakes the stream even when the body is unchanged + /// + /// A bare [`Event::HeatScheduled`] that *fills* a heat must NOT steal Live focus + /// ([`live_state`] keeps `current_heat` on the staged/last-transitioned heat), and it often does + /// not move `on_deck` either (that is the *earliest* still-scheduled heat — appending a third + /// heat behind it leaves it unchanged). So for a `LiveRaceState` scope the folded body can be + /// byte-identical across a `HeatScheduled` offset, and the plain change-suppression below would + /// emit nothing — leaving every client's `/heats`-derived list (the Live heat picker, the + /// Rounds & Heats list) stale until the next real transition. A scheduled heat is nonetheless a + /// real, observable change to the event's heat set, so we **force one fresh-value envelope** at a + /// `HeatScheduled` offset for the live-state scopes, re-sending the (possibly unchanged) body. + /// The client dedups by per-stream `sequence`, not by content, so re-sending the same body is + /// harmless; the extra envelope simply wakes consumers to re-read the heats list. `current_heat` + /// is untouched, so this never steals focus (the `current-heat` proof stays green). + fn advance( + &mut self, + events: &[StoredEvent], + overlay: Option<&crate::open_practice::OpenPracticeLive>, + ) -> Vec { + let mut out = Vec::new(); + let len = events.len() as u64; + // Whether this scope folds the live race-state (Event/Class/Heat): only those carry the + // heat set the `/heats` lists derive from, so only they need the schedule-wake re-emit. + let live_state_scope = self.projection.kind == ProjectionKind::LiveRaceState; + + // 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, None); + } + + while self.applied_offset < len { + self.applied_offset += 1; + let prefix = &events[..self.applied_offset as usize]; + // Whether the event newly folded at this offset schedules a heat — a real change to the + // event's heat set that must wake the heats lists even when the rendered body is unchanged + // (the fill-no-steal case; see the doc comment above). + let scheduled_heat = live_state_scope + && matches!( + prefix.last().map(|s| &s.event), + Some(Event::HeatScheduled { .. }) + ); + // The per-offset walk is over the pure log (no overlay) so logged-change sequencing is + // unaffected; the overlay re-snapshot is emitted once after the walk, below. + let body = ScopeProjection::fold(&self.scope, prefix, None); + if let Some(body) = body { + if scheduled_heat || self.last_emitted.as_ref() != Some(&body) { + out.push(StreamMessage::Change(self.envelope(body.clone()))); + self.last_emitted = Some(body); + } + } + } + + // The open-practice live re-snapshot (open-practice format, Slice 1): with an active + // open-practice heat, fold the current prefix and splice its per-channel laps onto the + // log-authoritative base, emitting when it differs from the last value — the non-logged laps + // reach the stream as a fresh-value `LiveRaceState` whose phase/clock are the log's. When the + // accumulator clears, this is skipped and the pure-log fold (above) is the last value emitted, + // so the console settles back onto the bare log state with no stale frame. + if overlay.is_some_and(|op| op.active_heat().is_some()) { + if let Some(body) = ScopeProjection::fold(&self.scope, events, overlay) { + 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(|_| ()) +} + +/// Send a [`ProtocolError`] to the client, then close the socket (best-effort). +/// +/// The full JSON error rides in a **text frame** (not the close frame): a WebSocket close +/// reason is a control frame capped at 123 bytes, which a descriptive error — a +/// version-mismatch naming the served band, a stale-cursor naming the window — readily +/// exceeds (`ControlFrameTooBig`). So the typed error is sent as data the client parses, +/// and the close frame carries only the short `POLICY` code and the error's machine code as +/// a tiny, always-in-bounds reason. The client reads the error frame, then sees the close. +async fn close_with(socket: &mut WebSocket, err: ProtocolError) { + let json = serde_json::to_string(&err).unwrap_or_else(|_| err.message.clone()); + let _ = socket.send(Message::Text(json.into())).await; + // A short, fixed reason that can never exceed the 123-byte control-frame limit. + let reason = format!("{:?}", err.code); + let _ = socket + .send(Message::Close(Some(axum::extract::ws::CloseFrame { + code: axum::extract::ws::close_code::POLICY, + reason: reason.into(), + }))) + .await; +} + +#[cfg(test)] +mod tests { + use super::*; + use gridfpv_events::{HeatId, HeatTransition}; + + /// Wrap a bare [`Event`] as a stored log entry (offset/timing irrelevant to the fold here). + fn stored(event: Event) -> StoredEvent { + StoredEvent { + offset: 0, + recorded_at: None, + event, + } + } + + fn scheduled(id: &str) -> Event { + Event::HeatScheduled { + heat: HeatId(id.into()), + lineup: vec![CompetitorRef("A".into()), CompetitorRef("B".into())], + class: None, + round: None, + frequencies: Vec::new(), + label: None, + } + } + + fn changed(id: &str, transition: HeatTransition) -> Event { + Event::HeatStateChanged { + heat: HeatId(id.into()), + transition, + } + } + + fn event_engine() -> Engine { + let scope = Scope::Event { + event: EventId("practice".into()), + }; + Engine::new(scope.clone(), ScopeProjection::of(&scope), 0) + } + + /// How many `Change` envelopes a batch of stream messages carries. + fn change_count(msgs: &[StreamMessage]) -> usize { + msgs.iter() + .filter(|m| matches!(m, StreamMessage::Change(_))) + .count() + } + + #[test] + fn scheduling_a_heat_wakes_the_stream_even_when_the_body_is_unchanged() { + // Reproduce the fill-no-steal staleness: q-1 is staged (current), q-2 is on deck. Scheduling + // a THIRD heat (q-3) appends behind the on-deck heat, so `current_heat` (still q-1) and + // `on_deck` (still q-2, the earliest scheduled) are unchanged — the folded `LiveRaceState` + // body is byte-identical. The stream must still emit an envelope so heats lists re-read. + let mut engine = event_engine(); + let log = vec![ + stored(scheduled("q-1")), + stored(changed("q-1", HeatTransition::Staged)), + stored(scheduled("q-2")), + ]; + // Catch up to the current state; the picker would now show q-1 + q-2. + let _ = engine.advance(&log, None); + + // Append q-3 — a bare schedule that does not move current/on-deck. + let mut log3 = log.clone(); + log3.push(stored(scheduled("q-3"))); + let out = engine.advance(&log3, None); + + // Exactly one fresh-value envelope is emitted for the schedule (the wake), even though the + // body did not change — so every console re-reads `/heats` and q-3 appears immediately. + assert_eq!(change_count(&out), 1, "a schedule must wake the stream"); + match &out[0] { + StreamMessage::Change(env) => match &env.change { + Change::FreshValue(ProjectionBody::LiveRaceState(live)) => { + // current_heat is unchanged — no focus steal. + assert_eq!(live.current_heat, Some(HeatId("q-1".into()))); + assert_eq!(live.on_deck, Some(HeatId("q-2".into()))); + } + other => panic!("expected a LiveRaceState fresh value, got {other:?}"), + }, + other => panic!("expected a Change envelope, got {other:?}"), + } + } + + #[test] + fn a_non_schedule_unchanged_fold_still_emits_nothing() { + // The wake is scoped to `HeatScheduled` offsets only — an offset that leaves the body + // unchanged for any other reason must stay suppressed (no spurious envelopes). + let mut engine = event_engine(); + let log = vec![ + stored(scheduled("q-1")), + stored(changed("q-1", HeatTransition::Staged)), + ]; + let _ = engine.advance(&log, None); + // Re-advancing over the SAME log (no new offsets) emits nothing. + let out = engine.advance(&log, None); + assert_eq!(change_count(&out), 0); + } +} diff --git a/crates/server/tests/control.rs b/crates/server/tests/control.rs new file mode 100644 index 0000000..1921837 --- /dev/null +++ b/crates/server/tests/control.rs @@ -0,0 +1,499 @@ +//! 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; +use gridfpv_server::app::router; +use gridfpv_server::control::{Command, CommandAck}; +use gridfpv_server::error::ErrorCode; +use gridfpv_server::events::{EventRegistry, PRACTICE_EVENT_ID}; +use gridfpv_server::scope::EventId; +use gridfpv_server::scope::{Scope, SubscribeRequest}; +use gridfpv_server::snapshot::{HeatPhase, ProjectionBody}; +use gridfpv_server::stream::{Change, StreamMessage}; +use tokio::net::TcpStream; +use tokio_tungstenite::tungstenite::http::StatusCode; +use tokio_tungstenite::tungstenite::http::Uri; +use tokio_tungstenite::tungstenite::{ClientRequestBuilder, Message}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; + +type Ws = WebSocketStream>; + +/// Serve `router(state)` on an ephemeral port; return the base address, a freshly-minted +/// **RD bearer token** (control is RD-gated since #44), and the server task handle (dropped +/// at test end, aborting the task). +async fn serve(registry: EventRegistry) -> (String, String, tokio::task::JoinHandle<()>) { + let rd_token = registry.tokens().issue_rd_token(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = router(registry); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("{addr}"), rd_token, handle) +} + +/// A fresh registry whose in-memory **Practice** event the control tests drive against, plus +/// its [`AppState`] (for token ops / direct appends). Every per-event path is rooted under +/// `/events/practice` (issue #72). +fn practice_registry() -> (EventRegistry, AppState) { + let registry = EventRegistry::new(None).unwrap(); + let state = registry + .resolve(&EventId(PRACTICE_EVENT_ID.into())) + .expect("Practice is always present"); + (registry, state) +} + +fn heat() -> HeatId { + HeatId("q-1".into()) +} + +/// The HTTP status `POST /control` returns for `command`, authenticated with the optional +/// bearer `token` (the raw status line code, so an auth rejection — 401 — is visible). +async fn post_status(addr: &str, command: &Command, token: Option<&str>) -> u16 { + let (status, _ack) = post_raw(addr, command, token).await; + status +} + +/// POST one command to `http://{addr}/control` with the optional bearer `token`; return the +/// HTTP status and (when the body parses) the [`CommandAck`]. +async fn post_raw(addr: &str, command: &Command, token: Option<&str>) -> (u16, Option) { + // 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 auth = token + .map(|t| format!("Authorization: Bearer {t}\r\n")) + .unwrap_or_default(); + let request = format!( + "POST /events/practice/control HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\ + {auth}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 status = response + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .expect("a status code on the response line"); + let ack = response + .split("\r\n\r\n") + .nth(1) + .and_then(|json| serde_json::from_str(json).ok()); + (status, ack) +} + +/// POST one command authenticated with an RD `token`, asserting it acks (200) and returning +/// the ack. The happy-path helper for the existing write-path tests. +async fn post_command(addr: &str, command: &Command, token: &str) -> CommandAck { + let (status, ack) = post_raw(addr, command, Some(token)).await; + assert_eq!(status, 200, "authenticated control should be admitted"); + ack.expect("body is a CommandAck") +} + +/// Connect the control WS at `ws://{addr}/control`, authenticated with the RD `token`. +async fn control_ws(addr: &str, token: &str) -> Ws { + let uri: Uri = format!("ws://{addr}/events/practice/control") + .parse() + .unwrap(); + let request = + ClientRequestBuilder::new(uri).with_header("Authorization", format!("Bearer {token}")); + let (ws, _) = connect_async(request).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}/events/practice/stream")) + .await + .unwrap(); + let request = SubscribeRequest { + scope, + from: None, + contract_version: None, + token: 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 (registry, _state) = practice_registry(); + let (addr, rd, _server) = serve(registry.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())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + &rd, + ) + .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() }, &rd).await; + assert!(ack.ok, "stage should ack ok: {ack:?}"); + assert_eq!(next_phase(&mut stream).await, HeatPhase::Staged); + + // Start (arms the heat), again read back off the stream. + let ack = post_command(&addr, &Command::Start { heat: heat() }, &rd).await; + assert!(ack.ok, "start should ack ok: {ack:?}"); + assert_eq!(next_phase(&mut stream).await, HeatPhase::Armed); +} + +/// `GET /control` (the bidirectional WS): a Stage→Start→SkipCountdown 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 (registry, _state) = practice_registry(); + let (addr, rd, _server) = serve(registry.clone()).await; + + let mut control = control_ws(&addr, &rd).await; + + // Schedule then subscribe. + let ack = send_command( + &mut control, + &Command::ScheduleHeat { + heat: heat(), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + ) + .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::Start { heat: heat() }, HeatPhase::Armed), + (Command::SkipCountdown { 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 (ForceEnd) acks ok. + let ack = send_command(&mut control, &Command::ForceEnd { heat: heat() }).await; + assert!(ack.ok, "force-end after running should ack ok: {ack:?}"); + // The `Finished` transition enters the `Unofficial` live-state phase. + assert_eq!(next_phase(&mut stream).await, HeatPhase::Unofficial); +} + +/// 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 (registry, _state) = practice_registry(); + let (addr, rd, _server) = serve(registry.clone()).await; + let mut control = control_ws(&addr, &rd).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![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + ) + .await; + assert!(ack.ok); +} + +/// `POST /control` is RD-token-gated (#44): no token, a read-only join-token, and a revoked +/// token are all rejected `401 Unauthorized`; a valid RD token is admitted (`200`). +#[tokio::test] +async fn control_post_requires_a_valid_rd_token() { + let (registry, state) = practice_registry(); + let join_token = state.tokens().issue_join_token(); + let (addr, rd, _server) = serve(registry.clone()).await; + let cmd = Command::ScheduleHeat { + heat: heat(), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }; + + // No token → 401. + assert_eq!(post_status(&addr, &cmd, None).await, 401); + // A read-only join-token never grants control → 401. + assert_eq!(post_status(&addr, &cmd, Some(&join_token)).await, 401); + // An unknown/garbage token → 401. + assert_eq!(post_status(&addr, &cmd, Some("not-a-token")).await, 401); + + // A valid RD token is admitted. + assert_eq!(post_status(&addr, &cmd, Some(&rd)).await, 200); + + // Revoke the RD token; it stops working. (Issue a second RD token first so control stays + // gated after the revoke — removing the *last* credential would open the Director.) + let keep = state.tokens().issue_rd_token(); + assert!(state.tokens().revoke(&rd)); + assert_eq!(post_status(&addr, &cmd, Some(&rd)).await, 401); + assert_eq!(post_status(&addr, &cmd, Some(&keep)).await, 200); +} + +/// `GET /control` (the WS upgrade) is gated the same way: a connection without a valid RD +/// token is refused at the HTTP handshake (a non-101 status), and the +/// [`StatusCode::UNAUTHORIZED`] is surfaced by the handshake error. +#[tokio::test] +async fn control_ws_upgrade_requires_a_valid_rd_token() { + let (registry, state) = practice_registry(); + let join_token = state.tokens().issue_join_token(); + let (addr, rd, _server) = serve(registry.clone()).await; + + // No token: the upgrade is refused. + let no_token = connect_async(format!("ws://{addr}/events/practice/control")).await; + assert!( + no_token.is_err(), + "an unauthenticated control upgrade is refused" + ); + + // A read-only join-token: still refused with 401. + let uri: Uri = format!("ws://{addr}/events/practice/control") + .parse() + .unwrap(); + let req = + ClientRequestBuilder::new(uri).with_header("Authorization", format!("Bearer {join_token}")); + match connect_async(req).await { + Err(tokio_tungstenite::tungstenite::Error::Http(resp)) => { + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + other => panic!("expected a 401 http error, got {other:?}"), + } + + // A valid RD token upgrades successfully. + let mut control = control_ws(&addr, &rd).await; + let ack = send_command( + &mut control, + &Command::ScheduleHeat { + heat: heat(), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + ) + .await; + assert!(ack.ok); +} + +/// Reads stay open on the LAN (#44, §5): a `/stream` subscribe with **no** token works, and +/// a read-only **join-token** authenticates a read all the same — neither grants control. +#[tokio::test] +async fn reads_are_open_and_a_join_token_authenticates_reads() { + let (registry, state) = practice_registry(); + let join_token = state.tokens().issue_join_token(); + let (addr, rd, _server) = serve(registry.clone()).await; + + // Schedule a heat (as the RD) so there is a live state to read. + let ack = post_command( + &addr, + &Command::ScheduleHeat { + heat: heat(), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + &rd, + ) + .await; + assert!(ack.ok); + + // An anonymous (no-token) reader gets the live state. + let mut anon = subscribe_stream(&addr, Scope::Heat { heat: heat() }).await; + assert_eq!(next_phase(&mut anon).await, HeatPhase::Scheduled); + + // A reader presenting the read-only join-token also gets the live state. + let (mut ws, _) = connect_async(format!("ws://{addr}/events/practice/stream")) + .await + .unwrap(); + let request = SubscribeRequest { + scope: Scope::Heat { heat: heat() }, + from: None, + contract_version: None, + token: Some(join_token), + }; + ws.send(Message::text(serde_json::to_string(&request).unwrap())) + .await + .unwrap(); + assert_eq!(next_phase(&mut ws).await, HeatPhase::Scheduled); +} + +/// Contract-version negotiation on the WS subscribe (#46, §7): an out-of-band version gets +/// the "please refresh" close (a `VersionMismatch` error in the close frame), while an +/// in-band version connects and streams. +#[tokio::test] +async fn out_of_band_contract_version_is_told_to_refresh() { + use gridfpv_server::ContractVersion; + use gridfpv_server::error::{ErrorCode, ProtocolError}; + + let (registry, _state) = practice_registry(); + let (addr, rd, _server) = serve(registry.clone()).await; + let ack = post_command( + &addr, + &Command::ScheduleHeat { + heat: heat(), + lineup: vec![CompetitorRef("A".into())], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + &rd, + ) + .await; + assert!(ack.ok); + + // Too-new version → the stream closes with a VersionMismatch refresh signal. + let (mut ws, _) = connect_async(format!("ws://{addr}/events/practice/stream")) + .await + .unwrap(); + let too_new = SubscribeRequest { + scope: Scope::Heat { heat: heat() }, + from: None, + contract_version: Some(ContractVersion::new(9_999)), + token: None, + }; + ws.send(Message::text(serde_json::to_string(&too_new).unwrap())) + .await + .unwrap(); + // The refresh signal is the typed error in a text frame (the close frame that follows + // carries only the short error code, to stay under the WS control-frame limit). + let frame = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .expect("timed out") + .expect("closed") + .expect("ws error"); + match frame { + Message::Text(text) => { + let err: ProtocolError = + serde_json::from_str(&text).expect("refresh frame is a ProtocolError"); + assert_eq!(err.code, ErrorCode::VersionMismatch); + } + other => panic!("expected a refresh error frame, got {other:?}"), + } + + // An in-band version connects and streams normally. + let (mut ws, _) = connect_async(format!("ws://{addr}/events/practice/stream")) + .await + .unwrap(); + let in_band = SubscribeRequest { + scope: Scope::Heat { heat: heat() }, + from: None, + contract_version: Some(gridfpv_server::CONTRACT_VERSION), + token: None, + }; + ws.send(Message::text(serde_json::to_string(&in_band).unwrap())) + .await + .unwrap(); + assert_eq!(next_phase(&mut ws).await, HeatPhase::Scheduled); +} diff --git a/crates/server/tests/full_event_live.rs b/crates/server/tests/full_event_live.rs new file mode 100644 index 0000000..fc08452 --- /dev/null +++ b/crates/server/tests/full_event_live.rs @@ -0,0 +1,613 @@ +//! Mock-RH end-to-end **server** test (#47) — the backend proof of v0.4's "a complete +//! event runs live ... against dockerized RotorHazard". +//! +//! Where the engine's `full_event_live` (#37) drives a whole event through the *pure* +//! engine, this drives a real heat through **dockerized RotorHazard into the running +//! protocol server's log** and observes it through a real **protocol client** — exactly +//! the path a phone / overlay takes (protocol.html §2–§5): +//! +//! 1. Stand up the server (`axum::serve` on an ephemeral port) over a fresh +//! [`EventRegistry`] (its built-in **Practice** event); the RD issues itself a token. +//! The router is event-rooted (#72), so every route is under `/events/{event}` and +//! every scope names that event. +//! 2. Drive a real heat against dockerized RH (the same plumbing as the engine's +//! `common::run_mock_heat`, inlined here): the **heat-loop transitions** go through the +//! privileged **control path** (`POST /control` with the RD token) — the way a real RD +//! drives the loop — while the timer's real **passes** are appended through +//! [`AppState::append`], the seam the source adapter writes through (§5: control is the +//! RD's surface; passes are observations the adapter ingests, not RD commands). +//! 3. Attach a protocol client: `GET /events/{event}/snapshot/...` for the initial +//! body+cursor, then a WS `/events/{event}/stream` subscribe `from` that cursor, and +//! assert the client receives **in-order** +//! change envelopes converging to the right [`LiveRaceState`] / [`LapList`]. +//! 4. Apply a **marshaling correction** (`VoidDetection`) via a control command and assert +//! the client observes the **re-folded** lap list (a fresh value, §9.2). +//! 5. Assert the **auth gate**: a control command without the RD token is rejected (401). +//! +//! # Determinism / tolerances +//! +//! RH's mock interface reads its CSV continuously (lap *timing* is not controllable) and +//! the harness stops the heat on the first crossing, so — like every `*_live` test — the +//! assertions are **structural / tolerant**: states reached, transition order, the change +//! stream converging, "a void removes exactly one detection". Never exact µs and never an +//! exact lap count (only `>= 1`). The transitions the *test itself* drives (the heat loop, +//! the marshaling correction) are deterministic; only the RH-produced passes are timing- +//! dependent, and those are asserted only by presence and by the re-fold delta. +//! +//! Local-only class (needs Docker). DISTINCT RH port 5041 (engine full-event uses 5040). +//! Run: +//! +//! ```sh +//! cargo test -p gridfpv-server --features live --test full_event_live -- --ignored --nocapture +//! ``` +#![cfg(feature = "live")] + +use std::time::{Duration, Instant}; + +use futures_util::{SinkExt, StreamExt}; +use gridfpv_adapters::rotorhazard::RotorHazardAdapter; +use gridfpv_adapters::rotorhazard::transport::RotorHazardConnection; +use gridfpv_events::{CompetitorRef, Event, HeatId, LogRef}; +use gridfpv_server::app::{AppState, router}; +use gridfpv_server::control::{Command, CommandAck}; +use gridfpv_server::events::{EventRegistry, PRACTICE_EVENT_ID}; +use gridfpv_server::scope::{EventId, PilotId, Scope, SubscribeRequest}; +use gridfpv_server::snapshot::{HeatPhase, LiveRaceState, ProjectionBody, Snapshot}; +use gridfpv_server::stream::{Change, StreamMessage}; +use gridfpv_testkit::{NodeCsv, RhContainer, node_csv}; +use tokio::net::TcpStream; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; + +type Ws = WebSocketStream>; + +/// DISTINCT RH host port for the server e2e (engine full-event 5040, heat e2e 5032). +const RH_PORT: u16 = 5041; +/// The CSV tick interval (seconds), matching the engine harness. +const TICK: &str = "0.1"; +/// The single heat this e2e drives. +const HEAT: &str = "q-e2e-1"; +/// The registry event this e2e drives against. Every route is rooted under +/// `/events/{EVENT}` and every scope names this event (issue #72 made the protocol +/// surface event-rooted). The built-in **Practice** event is always present in a fresh +/// [`EventRegistry`], so the test uses it rather than creating a bespoke one. +const EVENT: &str = PRACTICE_EVENT_ID; + +// --------------------------------------------------------------------------------------- +// Server / client plumbing (mirrors `tests/ws_stream.rs` + `tests/control.rs`). +// --------------------------------------------------------------------------------------- + +/// Serve `router(registry)` on an ephemeral port; return the base `127.0.0.1:port` +/// address and the server task handle (dropped at test end, aborting the task). The +/// router is event-rooted (#72), so it takes the whole [`EventRegistry`]. +async fn serve(registry: EventRegistry) -> (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(registry); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("{addr}"), handle) +} + +/// `POST /control` with the optional bearer `token`; return the HTTP status and (when the +/// body parses) the [`CommandAck`]. A tiny manual HTTP/1.1 POST so the test pulls in no +/// extra HTTP client dependency (the same shape `tests/control.rs` uses). +async fn post_raw(addr: &str, command: &Command, token: Option<&str>) -> (u16, Option) { + let body = serde_json::to_string(command).unwrap(); + let auth = token + .map(|t| format!("Authorization: Bearer {t}\r\n")) + .unwrap_or_default(); + let request = format!( + "POST /events/{EVENT}/control HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\ + {auth}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 status = response + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .expect("a status code on the response line"); + let ack = response + .split("\r\n\r\n") + .nth(1) + .and_then(|json| serde_json::from_str(json).ok()); + (status, ack) +} + +/// POST one command with the RD `token`, asserting it acks ok (200) — the RD's heat-loop / +/// marshaling driver. +async fn rd_command(addr: &str, command: &Command, token: &str) -> CommandAck { + let (status, ack) = post_raw(addr, command, Some(token)).await; + assert_eq!(status, 200, "RD control should be admitted (got {status})"); + let ack = ack.expect("body is a CommandAck"); + assert!(ack.ok, "RD command should ack ok: {ack:?}"); + ack +} + +/// GET a snapshot over a manual HTTP/1.1 request; return the parsed [`Snapshot`]. +async fn get_snapshot(addr: &str, path: &str) -> Snapshot { + let request = format!("GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"); + 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 status: u16 = response + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .expect("a status code"); + assert_eq!(status, 200, "snapshot GET {path} should be 200: {response}"); + let body = response.split("\r\n\r\n").nth(1).expect("a response body"); + serde_json::from_str(body).expect("parse Snapshot") +} + +/// GET a snapshot path and return only its HTTP status (for the post-void empty-scope case). +async fn pilot_snapshot_status(addr: &str, path: &str) -> u16 { + let request = format!("GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"); + 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(); + response + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .expect("a status code") +} + +/// Connect a `/stream` reader at `addr`, subscribing to `scope` from the snapshot `cursor`. +async fn subscribe(addr: &str, request: &SubscribeRequest) -> Ws { + let (mut ws, _) = connect_async(format!("ws://{addr}/events/{EVENT}/stream")) + .await + .unwrap(); + ws.send(Message::text(serde_json::to_string(request).unwrap())) + .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 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:?}"), + } +} + +/// The `LiveRaceState` carried by a fresh-value `Change` (the only encoding emitted today). +fn live_body(message: &StreamMessage) -> LiveRaceState { + match message { + StreamMessage::Change(env) => match &env.change { + Change::FreshValue(ProjectionBody::LiveRaceState(ls)) => ls.clone(), + other => panic!("expected a fresh-value live-state, got {other:?}"), + }, + other => panic!("expected a Change, got {other:?}"), + } +} + +/// Pump live-state envelopes until one whose `phase` equals `target` arrives (or a deadline +/// elapses), returning that envelope's sequence. Tolerant of the engine emitting one +/// envelope per fold-changing append (passes between transitions also bump the sequence). +async fn await_phase(ws: &mut Ws, target: HeatPhase) -> u64 { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let message = next_message(ws).await; + let ls = live_body(&message); + if ls.phase == target { + return seq(&message); + } + assert!( + Instant::now() < deadline, + "never observed phase {target:?} on the stream" + ); + } +} + +// --------------------------------------------------------------------------------------- +// Dockerized-RH driver (adapted from `engine/tests/common/mod.rs::run_mock_heat`). +// --------------------------------------------------------------------------------------- + +/// Poll `conn` until `pred` holds over the accumulated `sink`, or `timeout` elapses. +fn wait_until( + conn: &RotorHazardConnection, + sink: &mut Vec, + timeout: Duration, + pred: impl Fn(&[Event]) -> bool, +) -> bool { + let deadline = Instant::now() + timeout; + loop { + sink.extend(conn.events()); + if pred(sink) { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(250)); + } +} + +/// Connect to dockerized RH, reset it to a clean READY state, start the race, and collect +/// the real timer passes it produces. Returns the `Vec` events (other adapter +/// bookkeeping is dropped, exactly as the engine harness does). Blocking — run under +/// `spawn_blocking` so it does not stall the async server task. +fn run_rh_heat(rh_url: &str) -> Vec { + let conn = RotorHazardConnection::connect(rh_url, RotorHazardAdapter::new()) + .expect("connect to RotorHazard"); + + // Settle, then reset RH so staging starts from a known place. + std::thread::sleep(Duration::from_secs(2)); + conn.stop_race().ok(); + conn.discard_laps().expect("discard_laps"); + std::thread::sleep(Duration::from_secs(2)); + let _ = conn.events(); // drop the reset's snapshot churn + + // Actually start the race on RH (it stages + auto-starts). + conn.stage_race().expect("stage_race"); + let mut live: Vec = Vec::new(); + assert!( + wait_until(&conn, &mut live, Duration::from_secs(20), |evs| { + evs.iter() + .any(|e| matches!(e, Event::SessionStarted { .. })) + }), + "RotorHazard never reached RACING" + ); + + // Collect crossings; keep at least one pass. + let got_pass = wait_until(&conn, &mut live, Duration::from_secs(25), |evs| { + evs.iter().any(|e| matches!(e, Event::Pass(_))) + }); + + // Close the race, drain any final crossings. + conn.stop_race().ok(); + std::thread::sleep(Duration::from_millis(800)); + live.extend(conn.events()); + conn.disconnect(); + + assert!( + got_pass, + "no timer crossings were produced while the heat was running" + ); + + // Only `Pass`es are the heat's canonical race-engine observations. + live.into_iter() + .filter(|e| matches!(e, Event::Pass(_))) + .collect() +} + +// --------------------------------------------------------------------------------------- +// The e2e. +// --------------------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires Docker (spins up dockerized RotorHazard and drives a live heat through the server)"] +async fn a_live_heat_flows_through_the_server_to_a_protocol_client() { + // One busy node so several real passes land in the live window (the harness stops the + // race shortly after the first crossing). `node-0` is the seat ref the adapter reports. + let scenario = vec![( + 0usize, + node_csv(&NodeCsv { + ticks_per_lap: 2, + peak_rssi: 180, + baseline_rssi: 70, + seed: 0, + }), + )]; + let heat = HeatId(HEAT.into()); + let pilot = CompetitorRef("node-0".into()); + + // RAII: the container is dropped at the end of the test, removing it. + let rh = RhContainer::start(RH_PORT, TICK, &scenario); + let rh_url = rh.url().to_string(); + + // --- Stand up the server over a fresh registry; the RD issues itself a token. --- + // The router is event-rooted (#72): it serves the whole registry, and every request + // resolves the per-event `AppState`. We keep a handle to the Practice event's state + // (it shares the log + token store the router resolves) so the test can `append` real + // passes and read the log directly. + let registry = EventRegistry::new(None).expect("fresh registry"); + let state = registry + .resolve(&EventId(EVENT.into())) + .expect("Practice event is always present"); + let rd = registry.tokens().issue_rd_token(); + let (addr, _server) = serve(registry).await; + + // === 1. Schedule the heat via the control path (the RD's surface). === + rd_command( + &addr, + &Command::ScheduleHeat { + heat: heat.clone(), + lineup: vec![pilot.clone()], + class: None, + round: None, + frequencies: vec![], + label: None, + }, + &rd, + ) + .await; + + // === 2. Attach a protocol client: snapshot first, then subscribe from its cursor. === + // The event-scope snapshot is the whole-event live state; its cursor is the resume + // point so the stream begins exactly after the snapshot (§2, §3). + let snapshot = get_snapshot(&addr, &format!("/events/{EVENT}/snapshot/event/{EVENT}")).await; + let snap_live = match &snapshot.body { + ProjectionBody::LiveRaceState(ls) => ls.clone(), + other => panic!("expected a live-state snapshot, got {other:?}"), + }; + assert_eq!( + snap_live.current_heat, + Some(heat.clone()), + "the snapshot already reflects the scheduled heat" + ); + assert_eq!(snap_live.phase, HeatPhase::Scheduled); + assert_eq!(snap_live.active_pilots, vec![pilot.clone()]); + + let mut stream = subscribe( + &addr, + &SubscribeRequest { + scope: Scope::Event { + event: EventId(EVENT.into()), + }, + from: Some(snapshot.cursor), + contract_version: None, + token: None, + }, + ) + .await; + + // === 3. Drive the heat loop through the control path; the client reads it back. === + // Stage + Start are deterministic; the client must observe each phase transition in order + // (proving the control append reaches the read stream, §3, §5). + rd_command(&addr, &Command::Stage { heat: heat.clone() }, &rd).await; + let s_staged = await_phase(&mut stream, HeatPhase::Staged).await; + rd_command(&addr, &Command::Start { heat: heat.clone() }, &rd).await; + let s_armed = await_phase(&mut stream, HeatPhase::Armed).await; + assert!( + s_armed > s_staged, + "the per-stream sequence is strictly increasing (Staged {s_staged} < Armed {s_armed})" + ); + // SkipCountdown stands in for the runtime auto-start here (this test drives the loop by hand, + // not via the Director clock): force Armed -> Running. + rd_command(&addr, &Command::SkipCountdown { heat: heat.clone() }, &rd).await; + await_phase(&mut stream, HeatPhase::Running).await; + + // === 4. Run the real heat on dockerized RH; feed its passes through `append`. === + // This is the source-adapter seam (passes are observations, not RD commands). Driven on + // a blocking thread so the Socket.IO polling does not stall the async runtime. + let rh_url2 = rh_url.clone(); + let passes = tokio::task::spawn_blocking(move || run_rh_heat(&rh_url2)) + .await + .expect("RH driver thread"); + let pass_count = passes.len(); + assert!(pass_count >= 1, "the real heat produced at least one pass"); + for pass in passes { + state.append(pass, None).expect("append a real pass"); + } + + // Drain any change envelopes the passes produced (each pass that *completes* a lap + // changes the live-state fold and emits one), then read the converged live state off a + // fresh event-scope snapshot — the same projection the stream serves, so the snapshot + // and the stream agree (§2, §3). A single pass banks 0 completed laps, so the structural + // guarantee is that the live state still reflects this heat / pilot, Running. + drain_envelopes(&mut stream).await; + let folded_snap = get_snapshot(&addr, &format!("/events/{EVENT}/snapshot/event/{EVENT}")).await; + let folded = match &folded_snap.body { + ProjectionBody::LiveRaceState(ls) => ls.clone(), + other => panic!("expected a live-state snapshot, got {other:?}"), + }; + let live_laps = folded + .progress + .iter() + .find(|p| p.competitor == pilot) + .map(|p| p.laps_completed) + .unwrap_or(0); + assert_eq!(folded.current_heat, Some(heat.clone())); + assert_eq!(folded.phase, HeatPhase::Running); + assert!( + folded.active_pilots.contains(&pilot), + "the converged live state still carries the heat's pilot" + ); + + // === 5. The protocol client reads the pilot's lap list (snapshot scope). === + // The pilot scope folds to a `LapList`; its detection count is the marshaling baseline. + let pilot_snap = get_snapshot( + &addr, + &format!("/events/{EVENT}/snapshot/pilot/{EVENT}/node-0"), + ) + .await; + let baseline = lap_list_of(&pilot_snap.body); + let baseline_detections = detection_count(&baseline); + assert!( + baseline_detections >= 1, + "the pilot has at least one real detection to marshal; got {baseline_detections}" + ); + eprintln!( + "server e2e: {pass_count} real passes ⇒ {live_laps} completed laps, \ + {baseline_detections} detections" + ); + + // === 6. Marshaling correction: void one real detection via a control command. === + // Find a real `Pass` offset in the server's log and `VoidDetection` it through the RD + // control path, with a fresh pilot-scope client subscribed so it observes the re-fold. + let void_offset = first_pass_offset(&state).expect("a real pass to void"); + + // A fresh pilot-scope subscribe so the stream's seeded `last_emitted` is the *pre-void* + // lap list; the first envelope after the void is therefore the re-folded value. + let pilot_snap2 = get_snapshot( + &addr, + &format!("/events/{EVENT}/snapshot/pilot/{EVENT}/node-0"), + ) + .await; + let mut pilot_stream = subscribe( + &addr, + &SubscribeRequest { + scope: Scope::Pilot { + event: EventId(EVENT.into()), + pilot: PilotId("node-0".into()), + }, + from: Some(pilot_snap2.cursor), + contract_version: None, + token: None, + }, + ) + .await; + + rd_command( + &addr, + &Command::VoidDetection { + target: LogRef(void_offset), + }, + &rd, + ) + .await; + + if baseline_detections >= 2 { + // The void leaves at least one detection, so the pilot scope still folds to a + // non-empty lap list: the next envelope carries the re-folded value, one detection + // lighter — the client observes the marshaling correction as a fresh value (§9.2). + let marshaled = await_lap_list(&mut pilot_stream).await; + assert_eq!( + detection_count(&marshaled), + baseline_detections - 1, + "voiding one real detection re-folds the client's lap list down by exactly one" + ); + } else { + // Voiding the pilot's *only* detection empties the lap list, so the pilot scope + // folds to nothing and the snapshot 404s. Assert the re-fold by re-reading the + // pilot snapshot, which must now report the scope as unknown (no laps left). + assert_eq!( + pilot_snapshot_status( + &addr, + &format!("/events/{EVENT}/snapshot/pilot/{EVENT}/node-0") + ) + .await, + 404, + "voiding the only detection re-folds the pilot's lap list to empty (scope 404s)" + ); + } + + // === 7. Auth gate: a control command without the RD token is rejected (401). === + let (status, _ack) = post_raw(&addr, &Command::ForceEnd { heat: heat.clone() }, None).await; + assert_eq!( + status, 401, + "an un-authenticated control command is rejected" + ); + // A read-only join-token never grants control either. + let join = state.tokens().issue_join_token(); + let (status, _ack) = post_raw( + &addr, + &Command::ForceEnd { heat: heat.clone() }, + Some(&join), + ) + .await; + assert_eq!(status, 401, "a read-only join-token never grants control"); + // The same command WITH the RD token ends the heat (ForceEnd: Running -> Unofficial) — proving + // the gate admits the RD. + rd_command(&addr, &Command::ForceEnd { heat: heat.clone() }, &rd).await; + rd_command(&addr, &Command::Finalize { heat: heat.clone() }, &rd).await; + + // The event-scope client converges to the Final phase — the heat ran end to end. + await_phase(&mut stream, HeatPhase::Final).await; +} + +/// Drain any change envelopes already queued on the stream (the ones the appended passes +/// produced), without blocking once the stream goes quiet. Each drained frame must be a +/// well-formed live-state `Change` — proving the passes flowed through as ordered envelopes. +async fn drain_envelopes(ws: &mut Ws) { + loop { + match tokio::time::timeout(Duration::from_millis(500), ws.next()).await { + Ok(Some(Ok(Message::Text(text)))) => { + let message: StreamMessage = + serde_json::from_str(&text).expect("parse StreamMessage"); + // A live-state fresh value (the only thing an event scope emits). + let _ = live_body(&message); + } + Ok(Some(Ok(Message::Close(frame)))) => panic!("stream closed: {frame:?}"), + Ok(Some(Ok(_))) => continue, + Ok(Some(Err(e))) => panic!("websocket error: {e}"), + Ok(None) => return, + // No more frames within the quiet window: the stream has caught up. + Err(_) => return, + } + } +} + +/// Await the next pilot-scope envelope carrying a `LapList` fresh value. +async fn await_lap_list(ws: &mut Ws) -> gridfpv_projection::LapList { + match next_message(ws).await { + StreamMessage::Change(env) => match env.change { + Change::FreshValue(ProjectionBody::LapList(list)) => list, + other => panic!("expected a lap-list fresh value, got {other:?}"), + }, + other => panic!("expected a Change, got {other:?}"), + } +} + +/// The `LapList` carried by a pilot-scope snapshot body. +fn lap_list_of(body: &ProjectionBody) -> gridfpv_projection::LapList { + match body { + ProjectionBody::LapList(list) => list.clone(), + other => panic!("expected a lap-list body, got {other:?}"), + } +} + +/// Total detections (lap-gate passes) a corrected lap list holds: a competitor with `K` +/// laps had `K + 1` detections, so summing `laps + 1` over present competitors counts +/// detections. Enough to prove a void removed exactly one. +fn detection_count(list: &gridfpv_projection::LapList) -> usize { + list.competitors.iter().map(|c| c.laps.len() + 1).sum() +} + +/// The log offset of the first real `Pass` in the server's log (the marshaling target). +fn first_pass_offset(state: &AppState) -> Option { + let (events, _) = state.read_for_test(); + events + .iter() + .position(|e| matches!(e, Event::Pass(_))) + .map(|i| i as u64) +} + +// A small read accessor so the test can scan the server's log for a real pass offset. The +// server exposes `read` only `pub(crate)`; the test reaches the same log through the +// snapshot path instead. See `first_pass_offset` for how it is used. +trait ReadForTest { + fn read_for_test(&self) -> (Vec, gridfpv_server::stream::Cursor); +} + +impl ReadForTest for AppState { + fn read_for_test(&self) -> (Vec, gridfpv_server::stream::Cursor) { + // The log is shared behind `AppState::log()`; read it through the `EventSource` + // facade the server exposes (its methods are available on the `dyn` handle). + let log = self.log(); + let guard = log.lock().expect("log lock"); + let stored = guard.read_all().expect("read_all"); + let len = guard.len().expect("len"); + drop(guard); + let events = stored.into_iter().map(|s| s.event).collect(); + (events, gridfpv_server::stream::Cursor::new(len)) + } +} diff --git a/crates/server/tests/ws_stream.rs b/crates/server/tests/ws_stream.rs new file mode 100644 index 0000000..6747768 --- /dev/null +++ b/crates/server/tests/ws_stream.rs @@ -0,0 +1,378 @@ +//! 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::events::{EventRegistry, PRACTICE_EVENT_ID}; +use gridfpv_server::scope::{EventId, Scope, SubscribeRequest}; +use gridfpv_server::snapshot::{HeatPhase, ProjectionBody}; +use gridfpv_server::stream::{Change, Cursor, StreamMessage}; +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(registry: EventRegistry) -> (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(registry); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("ws://{addr}/events/practice/stream"), handle) +} + +/// A fresh registry plus its in-memory **Practice** [`AppState`] — the change-stream tests +/// append through Practice's log and subscribe under `/events/practice/stream` (issue #72). +fn practice_registry() -> (EventRegistry, AppState) { + let registry = EventRegistry::new(None).unwrap(); + let state = registry + .resolve(&EventId(PRACTICE_EVENT_ID.into())) + .expect("Practice is always present"); + (registry, state) +} + +/// 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(), + class: None, + round: None, + frequencies: vec![], + label: None, + } +} + +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 (registry, state) = practice_registry(); + let (url, _server) = serve(registry.clone()).await; + + let mut ws = subscribe( + &url, + &SubscribeRequest { + scope: event_scope(), + from: None, + contract_version: None, + token: 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 (registry, state) = practice_registry(); + let (url, _server) = serve(registry.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)), + contract_version: None, + token: None, + }, + ) + .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 (registry, state) = practice_registry(); + let (url, _server) = serve(registry.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)), + contract_version: None, + token: None, + }, + ) + .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 transition 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) — except +/// a `HeatScheduled`, which always wakes the stream so the heats lists re-read (see +/// `scheduling_a_heat_wakes_the_stream_even_when_the_body_is_unchanged`, below). +#[tokio::test] +async fn unchanged_fold_emits_no_envelope() { + let (registry, state) = practice_registry(); + let (url, _server) = serve(registry.clone()).await; + + let mut ws = subscribe( + &url, + &SubscribeRequest { + scope: event_scope(), + from: None, + contract_version: None, + token: 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-applying the same Running transition folds to the same live state → no new envelope. + // A following Running transition (after a fold-changing Staged) proves the no-op did not + // consume a sequence: drive Staged (a change, seq 2) then Running (a change, seq 3) and + // re-append Running (a no-op) — the next message read is the genuine Running at seq 3. + state + .append(heat_changed("q-1", HeatTransition::Staged), None) + .unwrap(); + let m2 = next_message(&mut ws).await; + assert_eq!(seq(&m2), 2); + assert_eq!(live_body(&m2).phase, HeatPhase::Staged); + + state + .append(heat_changed("q-1", HeatTransition::Running), None) + .unwrap(); + // A redundant Running transition folds to the SAME body → no envelope; it must not consume + // a sequence, so the Running message arrives at seq 3. + state + .append(heat_changed("q-1", HeatTransition::Running), None) + .unwrap(); + let m3 = next_message(&mut ws).await; + assert_eq!( + seq(&m3), + 3, + "the no-op transition did not consume a sequence" + ); + assert_eq!(live_body(&m3).phase, HeatPhase::Running); +} + +/// A bare `HeatScheduled` that *fills* a heat must wake the change stream even when the folded +/// `LiveRaceState` body is unchanged (fill-no-steal does not move `current_heat`, and an +/// appended heat behind the on-deck one does not move `on_deck`). The end-to-end proof that the +/// scheduled heat reaches consumers so the Live heat picker / Rounds & Heats list re-read +/// `/heats` without waiting for a transition. +#[tokio::test] +async fn scheduling_a_heat_wakes_the_stream_even_when_the_body_is_unchanged() { + let (registry, state) = practice_registry(); + let (url, _server) = serve(registry.clone()).await; + + let mut ws = subscribe( + &url, + &SubscribeRequest { + scope: event_scope(), + from: None, + contract_version: None, + token: None, + }, + ) + .await; + + // q-1 scheduled + staged (current), q-2 scheduled (on-deck). + state + .append(heat_scheduled("q-1", &["A", "B"]), None) + .unwrap(); + assert_eq!(seq(&next_message(&mut ws).await), 1); + state + .append(heat_changed("q-1", HeatTransition::Staged), None) + .unwrap(); + assert_eq!(seq(&next_message(&mut ws).await), 2); + state + .append(heat_scheduled("q-2", &["C", "D"]), None) + .unwrap(); + let m_ondeck = next_message(&mut ws).await; + assert_eq!(seq(&m_ondeck), 3); + assert_eq!(live_body(&m_ondeck).on_deck, Some(HeatId("q-2".into()))); + + // q-3 scheduled behind q-2: neither `current_heat` (still q-1) nor `on_deck` (still q-2) + // moves, so the folded body is unchanged — yet the schedule must still wake the stream. + state + .append(heat_scheduled("q-3", &["E", "F"]), None) + .unwrap(); + let m_wake = next_message(&mut ws).await; + assert_eq!(seq(&m_wake), 4, "a schedule wakes the stream"); + let live = live_body(&m_wake); + // current_heat is untouched (no focus steal) and on_deck is unchanged. + assert_eq!(live.current_heat, Some(HeatId("q-1".into()))); + assert_eq!(live.on_deck, Some(HeatId("q-2".into()))); +} + +/// 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 (registry, _state) = practice_registry(); + let (url, _server) = serve(registry).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:?}"), + } +} diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index ed2ecf0..7db0bb5 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gridfpv-storage" -version = "0.1.0" +version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/storage/src/sqlite.rs b/crates/storage/src/sqlite.rs index d8944a0..df8e2b1 100644 --- a/crates/storage/src/sqlite.rs +++ b/crates/storage/src/sqlite.rs @@ -21,6 +21,24 @@ //! `offset` is the `INTEGER PRIMARY KEY` it is the table's rowid: ordered scans //! and range reads are index lookups, and a duplicate offset would be rejected //! by the primary-key constraint — a structural guard for the append-only rule. +//! +//! ## Sidecar `meta` table (issue #111) +//! +//! Alongside the append-only `log`, a tiny key→value `meta` table holds an +//! event's **mutable configuration** (its [`EventMeta`](../../gridfpv_server) +//! name, timer selection, etc.) so an event's SQLite file is **self-contained**: +//! +//! ```sql +//! CREATE TABLE IF NOT EXISTS meta ( +//! key TEXT PRIMARY KEY, +//! value TEXT NOT NULL -- arbitrary string (JSON, in practice) +//! ); +//! ``` +//! +//! This is deliberately *not* part of the append-only log — it is config, not a +//! fact, so it is **upserted in place** (no offsets, no replay). The server +//! stores the event's `EventMeta` JSON here on create and on every meta change, +//! and reloads it on boot so created events survive a Director restart. use crate::{EventLog, Offset, Result, StorageError, StoredEvent}; use gridfpv_events::Event; @@ -61,9 +79,46 @@ impl SqliteLog { )", [], )?; + // The sidecar config table (issue #111): a tiny key→value store, separate + // from the append-only `log`, holding mutable per-event config (the + // server's `EventMeta` JSON) so the file is self-contained and a created + // event survives a restart. + conn.execute( + "CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )", + [], + )?; Ok(Self { conn }) } + /// Read a value from the sidecar `meta` table (issue #111), or `None` if the + /// key is absent. Used by the server to restore an event's `EventMeta` on boot. + pub fn get_meta(&self, key: &str) -> Result> { + let value: Option = self + .conn + .query_row( + "SELECT value FROM meta WHERE key = ?1", + params![key], + |row| row.get(0), + ) + .optional()?; + Ok(value) + } + + /// Upsert a value into the sidecar `meta` table (issue #111), overwriting any + /// prior value for `key`. Unlike the append-only `log`, the `meta` table is + /// mutable config — written on create and on every meta change. + pub fn set_meta(&self, key: &str, value: &str) -> Result<()> { + self.conn.execute( + "INSERT INTO meta (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value], + )?; + Ok(()) + } + /// The next offset to assign = one past the current maximum, or `0` when /// empty. Equivalent to the row count given the dense-from-`0` invariant. fn next_offset(conn: &Connection) -> Result { diff --git a/crates/storage/tests/behaviour.rs b/crates/storage/tests/behaviour.rs index fe08688..9cdc1c6 100644 --- a/crates/storage/tests/behaviour.rs +++ b/crates/storage/tests/behaviour.rs @@ -29,6 +29,7 @@ fn sample_events() -> Vec { sequence: Some(1), gate: GateIndex::LAP, signal: None, + heat: None, }), Event::Pass(Pass { adapter: adapter.clone(), @@ -37,6 +38,7 @@ fn sample_events() -> Vec { sequence: Some(2), gate: GateIndex(2), signal: None, + heat: None, }), Event::SessionEnded { adapter: adapter.clone(), @@ -164,6 +166,39 @@ fn sqlite_persists_across_reopen() { } } +#[test] +fn sqlite_meta_table_upserts_and_persists_across_reopen() { + // The sidecar `meta` table (issue #111): a missing key reads `None`, a set value reads + // back, a second set for the same key overwrites, and all of it survives a reopen — the + // server stores an event's `EventMeta` JSON here so created events survive a restart. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("event.sqlite"); + { + let log = SqliteLog::open(&path).unwrap(); + assert_eq!(log.get_meta("event_meta").unwrap(), None); + log.set_meta("event_meta", "{\"name\":\"Spring Cup\"}") + .unwrap(); + assert_eq!( + log.get_meta("event_meta").unwrap().as_deref(), + Some("{\"name\":\"Spring Cup\"}") + ); + // Upsert overwrites in place (config, not append-only). + log.set_meta("event_meta", "{\"name\":\"Summer Cup\"}") + .unwrap(); + assert_eq!( + log.get_meta("event_meta").unwrap().as_deref(), + Some("{\"name\":\"Summer Cup\"}") + ); + } + // A fresh connection reads the persisted value back. + let reopened = SqliteLog::open(&path).unwrap(); + assert_eq!( + reopened.get_meta("event_meta").unwrap().as_deref(), + Some("{\"name\":\"Summer Cup\"}") + ); + assert_eq!(reopened.get_meta("absent").unwrap(), None); +} + #[test] fn backends_agree_byte_for_byte() { // The two backends must produce identical StoredEvent sequences from the diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml index 5b2e82e..3590c6b 100644 --- a/crates/testkit/Cargo.toml +++ b/crates/testkit/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gridfpv-testkit" -version = "0.1.0" +version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index 811aa79..09fc439 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -44,32 +44,178 @@ use std::fs; use std::net::TcpStream; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, Instant}; /// How to synthesise one node's emulated output stream. +/// +/// The stream is **not** a square peak/nadir envelope: each lap is modelled as a low +/// [`NodeCsv::baseline_rssi`] floor plus a smooth Gaussian-ish bell centred on the gate crossing +/// (rise → peak → fall over the approach/depart window), with a few RSSI units of deterministic +/// per-sample noise on baseline *and* peak, and slight lap-to-lap variation in peak height. See +/// [`pass_profile`] for the model. pub struct NodeCsv { /// Ticks (CSV lines) between lap increments — roughly `lap_duration / - /// RH_UPDATE_INTERVAL`. Smaller = faster laps. + /// RH_UPDATE_INTERVAL`. At a realistic ~50–100 Hz sample rate (10–20 ms/tick) a lap is many + /// dozens of ticks, so the default is far denser than the old square-wave `6`. Smaller = + /// faster laps. See [`DEFAULT_TICKS_PER_LAP`]. pub ticks_per_lap: usize, - /// The node's peak RSSI, reported every tick so the node's `pass_peak_rssi` - /// (and thus the adapter's `SignalContext`) is a stable, assertable value. + /// The lap's **peak** RSSI: the height of the bell at the gate crossing (kept valid: 1..=999). + /// Lap-to-lap variation nudges this a few units per lap; the noise floor jitters it per sample. pub peak_rssi: i32, - /// Baseline RSSI between crossings (kept valid: 1..=999). + /// Baseline RSSI between crossings, while the craft is away from the gate (kept valid: 1..=999). pub baseline_rssi: i32, + /// Deterministic-RNG seed for this node's noise and lap-to-lap variation, so two nodes in the + /// same race get distinct (but reproducible) traces. [`race`] sets this to the node index; + /// single-node callers can leave it at the default `0`. + pub seed: u64, } impl Default for NodeCsv { fn default() -> Self { Self { - ticks_per_lap: 6, + ticks_per_lap: DEFAULT_TICKS_PER_LAP, peak_rssi: 150, baseline_rssi: 70, + seed: 0, } } } +/// Realistic default sample density. At a ~50–100 Hz mock tick (10–20 ms/sample) a real lap spans +/// many dozens of samples, so the bell profile is resolved smoothly rather than as a 6-point square. +/// The `cargo xtask rh-mock feed --tick T` knob sets the *wall-clock* seconds per tick (RH's +/// `RH_UPDATE_INTERVAL`); this constant sets how many ticks make up one lap in the generated CSV. +pub const DEFAULT_TICKS_PER_LAP: usize = 48; + +/// Half-width of the gate-pass bell, as a fraction of the lap window. The Gaussian peak occupies +/// roughly the final ~30% of the approach and the first ~30% of the depart around the crossing; the +/// rest of the lap sits at the noisy baseline floor while the craft is away from the gate. +const BELL_SIGMA_FRAC: f64 = 0.16; + +/// A tiny deterministic xorshift64* PRNG, seeded per `(node, sample)` so the noise is reproducible +/// across runs (the repo bans `Math.random`/wall-clock in generators). Returns a value in `0.0..1.0`. +fn det_unit(node_seed: u64, sample: u64, salt: u64) -> f64 { + // SplitMix-style avalanche of the keyed input, then one xorshift* step. + let mut x = node_seed + .wrapping_mul(0x9E37_79B9_7F4A_7C15) + .wrapping_add(sample.wrapping_mul(0xD1B5_4A32_D192_ED03)) + .wrapping_add(salt.wrapping_mul(0xCA5A_8267_6F49_5C2D)) + .wrapping_add(0x2545_F491_4F6C_DD1D); + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + let r = x.wrapping_mul(0x2545_F491_4F6C_DD1D); + // Top 53 bits -> [0,1). + (r >> 11) as f64 / (1u64 << 53) as f64 +} + +/// Deterministic symmetric noise in `±amp` RSSI units for one sample, keyed by node+sample. +fn det_noise(node_seed: u64, sample: u64, salt: u64, amp: f64) -> f64 { + (det_unit(node_seed, sample, salt) - 0.5) * 2.0 * amp +} + +/// The realistic per-tick RSSI **value** for one node at sample `i`, given the **crossing tick** it +/// is nearest to (`cross_tick`), the lap window (`tpl` ticks), the lap's peak, the node baseline, +/// and the node seed. +/// +/// Model (what real per-gate-pass RSSI looks like): +/// - a low **baseline floor** while the craft is away from the gate, +/// - a smooth **Gaussian bell** centred on `cross_tick` (rise → peak → fall over the approach and +/// departure window) — not a square step, +/// - small **deterministic noise** (a few RSSI units) so the trace is never perfectly smooth, yet +/// stays reproducible (seeded xorshift, no wall-clock). The noise is *shaped* (see the body): full +/// ± jitter on the off-gate baseline, but rectified **upward** at the crest so the reported peak +/// never dips below the lap's true peak (`node_peak`/`pass_peak` are running maxima). That keeps +/// the crossing **reliably detectable** — its captured peak always clears RH's calibrated `enter` +/// by the lap's full peak-to-baseline margin — while the baseline stays clear of `exit`. +/// +/// `noise_amp` is the per-sample jitter amplitude (scenarios raise it for a "noisy" feed). The bell +/// peaks **at** `cross_tick`, where RH's crossing detection fires (`cross=T`), so the signal maximum +/// and the recorded crossing align. Returned value is clamped valid (`1..=999`). +fn pass_value( + node_seed: u64, + sample: usize, + cross_tick: usize, + tpl: usize, + peak: i32, + baseline: i32, + noise_amp: f64, +) -> i32 { + let window = tpl.max(1) as f64; + // Signed distance (in lap-fractions) from this sample to the nearest crossing. + let dist = (cross_tick as f64 - sample as f64) / window; + // Gaussian bell: 1.0 at the crossing, decaying to ~baseline as the craft moves off-gate. + let sigma = BELL_SIGMA_FRAC.max(1e-3); + let bell = (-(dist * dist) / (2.0 * sigma * sigma)).exp(); + let span = (peak - baseline) as f64; + let smooth = baseline as f64 + span * bell; + // Noise model, shaped so the trace stays realistic *and* the crossing stays detectable: + // + // - **Off-gate (baseline)** carries full bidirectional jitter (`±noise_amp`) so the floor has + // real texture — but its amplitude is bounded (see [`BASE_NOISE`]) to stay clear of RH's exit + // threshold, so the detector cleanly drops below `exit` between passes and re-arms. + // - **Near the crest** the jitter is *rectified upward* (`bell`-weighted): `node_peak`/`pass_peak` + // are running **maxima** on a real timer, so sensor noise around a pass crest only ever pushes + // the reported peak *up*, never carves a notch below the true peak. This keeps the crest the + // unambiguous maximum it physically is, guaranteeing the captured peak clears RH's calibrated + // `enter` with the lap's full peak-to-baseline margin (no noise-induced dip below threshold). + // + // The two blend by `bell`: pure ± noise at the floor, pure additive crest noise at the peak. + let floor_noise = det_noise(node_seed, sample as u64, 0xA1, noise_amp) * (1.0 - bell); + let crest_noise = det_unit(node_seed, sample as u64, 0xB2) * noise_amp * 0.4 * bell; + (smooth + floor_noise + crest_noise) + .round() + .clamp(1.0, 999.0) as i32 +} + +/// Lap-to-lap variation: nudge a lap's peak height a few RSSI units (deterministically, keyed by +/// node+lap) so no two passes are identical. Returns the varied peak, clamped valid. +fn varied_peak(node_seed: u64, lap_index: usize, peak: i32) -> i32 { + // ±~4% of the peak, at least ±2 units, so even low peaks vary visibly. + let amp = (peak as f64 * 0.04).max(2.0); + (peak as f64 + det_noise(node_seed, lap_index as u64, 0xC3, amp)) + .round() + .clamp(1.0, 999.0) as i32 +} + +/// Render one CSV row (one mock tick) in RotorHazard `MockInterface` column order. +/// +/// Columns: `idx, lap_id, ms, rssi, node_peak, pass_peak, loop_time, cross(T/F), +/// pass_nadir, node_nadir, peakRssi, pkFirst, pkLast, nadirRssi, ndFirst, ndLast`. +/// +/// Two signal fidelities ride in one row, and both now carry the realistic gate-pass shape: +/// - **Coarse** — `node_peak` (col 4) is what `node_data` streams and the adapter's coarse +/// [`SignalChunk`] trace samples once per heartbeat. It carries the per-tick **bell + noise** +/// value so the *live* trace already looks like real RSSI (rise → peak → fall, not a square step). +/// - **Dense** — the `peakRssi`/`nadirRssi` history columns (10/13) with their first/last times +/// (11/12, 14/15) feed RotorHazard's per-tick `history_values`/`history_times` accumulator +/// (`BaseHardwareInterface.PeakNadirHistory.addTo`), the dense trace its marshal page reviews and +/// our path-2 (`current_marshal_data`/`get_pilotrace`) pulls at heat end. Distinct **first/last** +/// times (`pkFirst > pkLast`) make RH log *two* history entries per tick, raising density; the +/// per-tick `peak_rssi_hist`/`nadir_rssi_hist` carry the same bell-shaped, noisy envelope so the +/// captured dense trace has real texture at full per-tick resolution — what the graph is judged on. +#[allow(clippy::too_many_arguments)] +fn csv_row( + lap_id: usize, + rssi: i32, + node_peak: i32, + pass_peak: i32, + cross: char, + peak_rssi_hist: i32, + nadir_rssi_hist: i32, +) -> String { + // Distinct first/last times so RH's `addTo` records two entries per peak and per nadir each + // tick (`pkFirst > pkLast` / `ndFirst > ndLast`), raising the dense-history density. Small, + // fixed, positive values keep them well clear of the "corrupted history times" guard. + format!( + "0,{lap_id},0,{rssi},{node_peak},{pass_peak},10,{cross},20,30,{peak},2,1,{nadir},2,1", + peak = peak_rssi_hist, + nadir = nadir_rssi_hist, + ) +} + /// Render one node's `mock_data_{N}.csv` content. /// /// Columns (RotorHazard `MockInterface`): `idx, lap_id, ms, rssi, node_peak, @@ -83,24 +229,44 @@ impl Default for NodeCsv { /// rather than being baked in up front — capping `lap_id` would stop producing laps /// before the race even begins. At EOF the file loops, which simply keeps laps /// coming (each increment still differs from the last seen id). +/// +/// Both the coarse `node_peak` (col 4) and the dense history columns (10/13) now carry the +/// realistic gate-pass value — a baseline floor plus a Gaussian bell centred on each crossing, with +/// deterministic per-sample noise (see [`pass_value`]) and slight lap-to-lap peak variation (see +/// [`varied_peak`]). The peak lands exactly on the crossing tick, so RH's crossing detection still +/// fires there and laps still record. pub fn node_csv(opts: &NodeCsv) -> String { - const TOTAL_TICKS: usize = 600; - let node_peak = opts.peak_rssi + 30; + let tpl = opts.ticks_per_lap.max(1); let mut lines = Vec::with_capacity(TOTAL_TICKS); for i in 0..TOTAL_TICKS { - let lap_id = i / opts.ticks_per_lap; - let on_lap = i > 0 && i % opts.ticks_per_lap == 0; - let rssi = if on_lap { - opts.peak_rssi - } else { - opts.baseline_rssi - }; + let lap_id = i / tpl; + let on_lap = i > 0 && i % tpl == 0; + // The crossing tick this sample is nearest to frames the bell (crossings are at multiples + // of `tpl`); the peak lands on it so detection and signal maximum align. + let cross_idx = (i + tpl / 2) / tpl; + let cross_tick = cross_idx * tpl; + // That crossing's lap-varied peak (so each pass differs slightly). + let peak = varied_peak(opts.seed, cross_idx.max(1), opts.peak_rssi); + let value = pass_value( + opts.seed, + i, + cross_tick, + tpl, + peak, + opts.baseline_rssi, + BASE_NOISE, + ); let cross = if on_lap { 'T' } else { 'F' }; - // pass_peak is reported on EVERY tick so the node's signal level is a stable - // value the adapter can cache and assert (not just on lap ticks). - lines.push(format!( - "0,{lap_id},0,{rssi},{node_peak},{peak},10,{cross},20,30,{peak},0,0,20,0,0", - peak = opts.peak_rssi, + // pass_peak reports the lap's (varied) peak on EVERY tick so the node's signal level is a + // stable, cacheable context value the adapter asserts (not the noisy per-tick value). + lines.push(csv_row( + lap_id, + value, + value, + peak, + cross, + value, + opts.baseline_rssi, )); } let mut out = lines.join("\n"); @@ -108,6 +274,10 @@ pub fn node_csv(opts: &NodeCsv) -> String { out } +/// Default per-sample baseline/peak noise amplitude (RSSI units, ±). A few units of jitter on +/// everything so the trace is never perfectly smooth. Scenarios raise this for a "noisy" feed. +pub const BASE_NOISE: f64 = 3.0; + // --------------------------------------------------------------------------- // Scenario library: per-node lap schedules (`NodePlan` / `LapSpec` / `plan_csv`) // --------------------------------------------------------------------------- @@ -174,6 +344,12 @@ pub struct NodePlan { pub peak_rssi: i32, /// Baseline RSSI reported between crossings (kept valid: 1..=999). pub baseline_rssi: i32, + /// Per-sample noise amplitude (RSSI units, ±) for this node's trace. Defaults to [`BASE_NOISE`]; + /// the `noisy` scenario raises it for a dirtier baseline. + pub noise_amp: f64, + /// Deterministic-RNG seed for this node's noise and lap-to-lap variation (see [`NodeCsv::seed`]). + /// [`race`] sets it to the node index so co-racing nodes get distinct, reproducible traces. + pub seed: u64, } impl Default for NodePlan { @@ -182,6 +358,8 @@ impl Default for NodePlan { laps: Vec::new(), peak_rssi: 150, baseline_rssi: 70, + noise_amp: BASE_NOISE, + seed: 0, } } } @@ -213,36 +391,77 @@ impl NodePlan { /// reports `baseline_rssi` and `cross = F`. `pass_peak` carries the lap's peak on /// *every* row so the node's signal level stays a stable, assertable value (it /// reflects the most recent crossing, like the real node's `pass_peak_rssi`). +/// +/// As in [`node_csv`], both the coarse `node_peak` (col 4) and the dense history columns (10/13) +/// carry the realistic gate-pass value: a baseline floor plus a Gaussian bell centred on each +/// scheduled crossing (the bell peaks *on* the `cross=T` tick so detection still fires), with +/// deterministic per-sample noise (see [`pass_value`]) and slight lap-to-lap peak variation (see +/// [`varied_peak`]). `pass_peak` (col 5) holds the most-recent crossing's nominal peak as the stable +/// signal-context value the adapter caches. pub fn plan_csv(plan: &NodePlan) -> String { let crossings = plan.crossing_ticks(); - // Map tick -> peak for fast lookup, and precompute the running lap_id. - let mut next = 0usize; // index into crossings + // Resolve each crossing's nominal peak (plan default unless the lap overrides it), then apply + // lap-to-lap variation so no two passes are identical. + let peaks: Vec = crossings + .iter() + .enumerate() + .map(|(li, _)| { + let nominal = match plan.laps.get(li) { + Some(s) if s.peak_rssi != 0 => s.peak_rssi, + _ => plan.peak_rssi, + }; + varied_peak(plan.seed, li + 1, nominal) + }) + .collect(); + let mut lap_id = 0usize; - // Most recent crossing peak, for the per-row pass_peak (signal context). - let mut last_peak = plan.peak_rssi; + // Most recent crossing's NOMINAL peak (unvaried), for the per-row pass_peak signal context — + // kept stable so signal-magnitude assertions read a clean value. + let mut last_nominal = plan.peak_rssi; + let mut next = 0usize; // index into crossings let mut lines = Vec::with_capacity(TOTAL_TICKS); for i in 0..TOTAL_TICKS { let on_lap = next < crossings.len() && crossings[next] == i; if on_lap { lap_id += 1; - let spec = &plan.laps[lap_id - 1]; - last_peak = if spec.peak_rssi != 0 { - spec.peak_rssi - } else { - plan.peak_rssi + last_nominal = match plan.laps.get(lap_id - 1) { + Some(s) if s.peak_rssi != 0 => s.peak_rssi, + _ => plan.peak_rssi, }; next += 1; } - let node_peak = last_peak + 30; - let rssi = if on_lap { - last_peak + let cross = if on_lap { 'T' } else { 'F' }; + + // The realistic per-tick value: the nearest scheduled crossing's bell over the baseline. + let value = if let Some(ci) = nearest_crossing(&crossings, i) { + let cross_tick = crossings[ci]; + // Local window = distance to the nearer neighbouring crossing (sets the bell width to + // this lap's pace). Falls back to a sane default with only one crossing. + let win = local_window(&crossings, ci); + pass_value( + plan.seed, + i, + cross_tick, + win, + peaks[ci], + plan.baseline_rssi, + plan.noise_amp, + ) } else { - plan.baseline_rssi + // No crossings (empty plan): a flat, noisy baseline floor. + (plan.baseline_rssi as f64 + det_noise(plan.seed, i as u64, 0xA1, plan.noise_amp)) + .round() + .clamp(1.0, 999.0) as i32 }; - let cross = if on_lap { 'T' } else { 'F' }; - lines.push(format!( - "0,{lap_id},0,{rssi},{node_peak},{peak},10,{cross},20,30,{peak},0,0,20,0,0", - peak = last_peak, + + lines.push(csv_row( + lap_id, + value, + value, + last_nominal, + cross, + value, + plan.baseline_rssi, )); } let mut out = lines.join("\n"); @@ -250,6 +469,30 @@ pub fn plan_csv(plan: &NodePlan) -> String { out } +/// Index into `crossings` of the crossing nearest to sample `i`, or `None` if there are none. +fn nearest_crossing(crossings: &[usize], i: usize) -> Option { + crossings + .iter() + .enumerate() + .min_by_key(|&(_, &t)| t.abs_diff(i)) + .map(|(ci, _)| ci) +} + +/// The bell-width window for crossing `ci`: the gap to its nearer neighbour (so a fast lap gets a +/// narrow bell and a slow one a wide bell). With a single crossing, defaults to [`DEFAULT_TICKS_PER_LAP`]. +fn local_window(crossings: &[usize], ci: usize) -> usize { + let here = crossings[ci]; + let prev = ci.checked_sub(1).map(|p| here - crossings[p]); + let next = crossings.get(ci + 1).map(|&n| n - here); + match (prev, next) { + (Some(a), Some(b)) => a.min(b), + (Some(a), None) => a, + (None, Some(b)) => b, + (None, None) => DEFAULT_TICKS_PER_LAP, + } + .max(1) +} + /// The ready-made scenario menu: high-level constructors that each describe a /// real-world race situation, returning a [`NodePlan`] (single node) or a full /// multi-node `(node_index, csv)` list. @@ -265,9 +508,13 @@ pub mod scenarios { pub const STRONG_PEAK: i32 = 200; /// A clearly-detected-but-weak peak (above threshold, below [`STRONG_PEAK`]). pub const WEAK_PEAK: i32 = 120; - /// A *marginal* peak: just above the enter threshold — a pass that barely - /// registers (dirty signal, gate at the edge of range). - pub const MARGINAL_PEAK: i32 = ENTER_THRESHOLD + 5; + /// A *marginal* peak: above the enter threshold but with only a slim margin — a pass that + /// registers, but is the weakest the model still reliably detects (dirty signal, gate at the edge + /// of range). The margin (`+15`) is sized so the bell crest clears RH's calibrated `enter` even + /// after lap-to-lap peak variation (`varied_peak` can shave a few percent off a low peak), so the + /// pass *always* records — "marginal" in magnitude, not in reliability. It stays clearly below + /// [`WEAK_PEAK`] so the `strong > weak > marginal` ordering the signal-context tests assert holds. + pub const MARGINAL_PEAK: i32 = ENTER_THRESHOLD + 15; /// **Uniform pace** — `laps` crossings, every `gap` ticks, strong signal. /// @@ -390,6 +637,26 @@ pub mod scenarios { ..NodePlan::default() } } + + /// **Noisy / dirty channel** — `laps` marginal-peak laps with a much louder per-sample noise + /// floor (`noise_amp` ~3× the [`super::BASE_NOISE`] default). + /// + /// Simulates: a dirty RF environment — a jittery baseline and a marginal peak that only just + /// clears the enter threshold. Distinct from [`marginal`] (clean baseline) in the *texture* of + /// the trace: the marshaling graph should look genuinely grainy. + /// + /// Assert: laps still record (the peak clears threshold despite the noise); the captured trace + /// has a visibly higher baseline variance than a clean node. + pub fn noisy(laps: usize, gap: usize) -> NodePlan { + NodePlan { + laps: (0..laps) + .map(|_| LapSpec::with_peak(gap, MARGINAL_PEAK + 10)) + .collect(), + peak_rssi: MARGINAL_PEAK + 10, + noise_amp: super::BASE_NOISE * 3.0, + ..NodePlan::default() + } + } } // --------------------------------------------------------------------------- @@ -408,7 +675,15 @@ pub fn race(plans: &[NodePlan]) -> Vec<(usize, String)> { plans .iter() .enumerate() - .map(|(i, plan)| (i, plan_csv(plan))) + .map(|(i, plan)| { + // Key each node's deterministic noise/variation to its index so co-racing nodes get + // distinct (but reproducible) traces, even from identical plans (e.g. a `pack`). + let seeded = NodePlan { + seed: i as u64, + ..plan.clone() + }; + (i, plan_csv(&seeded)) + }) .collect() } @@ -430,6 +705,25 @@ pub fn simultaneous(nodes: usize, laps: usize, gap: usize) -> Vec<(usize, String race(&plans) } +/// The locally-built RotorHazard harness image (RH **v4.4.0**, mock nodes, +/// `MOCK_NODE_SIGNAL=1` so `mock_data_{N}.csv` drives the signal). Built from +/// `docker/rotorhazard/Dockerfile`; [`RhContainer::start`] builds it on demand if it +/// is missing, so `cargo xtask live` stays a one-command run. +/// +/// This **replaces** the old `cruwaller/rotorhazard:latest` pull, which is stale at +/// 4.0.0-beta.4 — below the GridFPV plugin floor (RHAPI 1.3 / RH v4.3.0+, D16). +pub const RH_IMAGE: &str = "gridfpv-rotorhazard:4.4.0"; + +/// The server dir inside the image: `DATA_DIR` (= `PROGRAM_DIR`), so `mock_data_{N}.csv` +/// and the user `plugins/` dir both resolve here (see `docker/rotorhazard/Dockerfile`). +const RH_SERVER_DIR: &str = "/opt/RotorHazard/src/server"; + +/// Env var pointing at a host plugin directory to mount into the container's user +/// `plugins/` dir as `plugins/gridfpv` (read-only). Set by `cargo xtask live` / +/// `rh-mock` so live runs boot against the GridFPV plugin; unset = stock RH (the +/// socket-fallback path). Mounting an empty/placeholder plugin is harmless. +pub const PLUGIN_ENV: &str = "GRIDFPV_RH_PLUGIN"; + /// A disposable dockerized RotorHazard for a single test, with emulated node CSVs /// mounted. Removed on drop. pub struct RhContainer { @@ -442,7 +736,45 @@ impl RhContainer { /// Start RotorHazard on `port` with `csvs` (one per node, 0-based node index) /// mounted as `mock_data_{index+1}.csv`, ticking every `update_interval` /// seconds. Blocks until the HTTP port accepts connections. + /// + /// The GridFPV plugin is mounted when the [`PLUGIN_ENV`] env var points at a plugin + /// directory — the path `cargo xtask live` uses to boot every live RH against the + /// plugin. In-process callers that want to mount a specific plugin explicitly use + /// [`RhContainer::start_with_plugin`]. pub fn start(port: u16, update_interval: &str, csvs: &[(usize, String)]) -> Self { + Self::start_with_plugin(port, update_interval, csvs, None) + } + + /// Like [`RhContainer::start`], but with an explicit GridFPV `plugin_dir` to mount into the + /// container's user `plugins/gridfpv` (read-only). `Some(dir)` takes precedence; with `None` + /// the [`PLUGIN_ENV`] env var is consulted as a fallback (the path `cargo xtask live` uses). + /// For mounting additional plugins (e.g. the test-only `gridfpv_mock`) use + /// [`start_with_plugins`](Self::start_with_plugins). + pub fn start_with_plugin( + port: u16, + update_interval: &str, + csvs: &[(usize, String)], + plugin_dir: Option, + ) -> Self { + let plugin_dir = plugin_dir.or_else(|| std::env::var_os(PLUGIN_ENV).map(PathBuf::from)); + let plugins: Vec<(&str, PathBuf)> = + plugin_dir.into_iter().map(|dir| ("gridfpv", dir)).collect(); + let refs: Vec<(&str, &Path)> = plugins.iter().map(|(n, d)| (*n, d.as_path())).collect(); + Self::start_with_plugins(port, update_interval, csvs, &refs) + } + + /// Start RotorHazard with **any number of plugins** mounted (read-only) into the container's + /// user `plugins/` dirs — each `(folder, host_dir)`. Used by `cargo xtask rh-mock` to + /// boot the real `gridfpv` plugin and/or the test-only `gridfpv_mock` control plugin together + /// (xtask can't set process env under `#![forbid(unsafe_code)]`, so it passes dirs explicitly). + pub fn start_with_plugins( + port: u16, + update_interval: &str, + csvs: &[(usize, String)], + plugins: &[(&str, &Path)], + ) -> Self { + ensure_image(); + let name = format!("gridfpv-rh-sig-{port}"); // Clean any leftover from a previous aborted run. let _ = Command::new("docker").args(["rm", "-f", &name]).output(); @@ -466,12 +798,28 @@ impl RhContainer { fs::write(&file, content).expect("write mock_data csv"); args.push("-v".into()); args.push(format!( - "{}:/root/RotorHazard/src/server/mock_data_{}.csv", + "{}:{RH_SERVER_DIR}/mock_data_{}.csv", file.display(), node_index + 1 )); } - args.push("cruwaller/rotorhazard:latest".into()); + // Mount each requested plugin into the container's user `plugins/` (read-only). + // Plugins are additive — a placeholder loads cleanly and stock-RH behavior is unchanged. + for (folder, dir) in plugins { + if dir.is_dir() { + args.push("-v".into()); + args.push(format!( + "{}:{RH_SERVER_DIR}/plugins/{folder}:ro", + dir.display() + )); + } else { + eprintln!( + "plugin dir {} for `{folder}` is not a directory; skipping that mount", + dir.display() + ); + } + } + args.push(RH_IMAGE.into()); let out = Command::new("docker") .args(&args) @@ -498,6 +846,35 @@ impl RhContainer { pub fn url(&self) -> &str { &self.url } + + /// The container's docker name — so a test can drive its lifecycle directly (e.g. `docker stop` + /// it to simulate a RotorHazard drop-off). Final cleanup still happens via the RAII `Drop`. + pub fn name(&self) -> &str { + &self.name + } + + /// The container's combined stdout+stderr so far (`docker logs`). Used by + /// `cargo xtask rh-mock plugin-check` to assert the GridFPV plugin loaded. + pub fn logs(&self) -> String { + Command::new("docker") + .args(["logs", &self.name]) + .output() + .map(|o| { + let mut s = String::from_utf8_lossy(&o.stdout).into_owned(); + s.push_str(&String::from_utf8_lossy(&o.stderr)); + s + }) + .unwrap_or_default() + } + + /// Stop the container (a real RotorHazard drop-off) without removing it — the link the app holds + /// is severed, so the driver's liveness monitor should observe the drop. `Drop` still removes + /// the (now-stopped) container at end of test. Used by the drop-detection live test (#105). + pub fn stop(&self) { + let _ = Command::new("docker") + .args(["stop", "-t", "0", &self.name]) + .output(); + } } impl Drop for RhContainer { @@ -509,6 +886,44 @@ impl Drop for RhContainer { } } +/// Ensure the [`RH_IMAGE`] exists locally, building it from +/// `docker/rotorhazard/Dockerfile` if not. Keeps `cargo xtask live`/`rh-mock` a single +/// command now that the harness uses a locally-built image (RH v4.4.0) instead of a +/// Docker Hub pull. The first build takes a couple of minutes; subsequent runs are a +/// no-op (cached). Panics if the build fails — a live test cannot proceed without it. +fn ensure_image() { + let present = Command::new("docker") + .args(["image", "inspect", RH_IMAGE]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if present { + return; + } + + // `/../../docker/rotorhazard` — the build context (Dockerfile + mock CSV + + // config.json + entrypoint). + let context = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../docker/rotorhazard") + .canonicalize() + .expect("locate docker/rotorhazard build context"); + + eprintln!( + "\x1b[1mBuilding {RH_IMAGE}\x1b[0m from {} (first run only; ~1–2 min)…", + context.display() + ); + let status = Command::new("docker") + .args(["build", "-t", RH_IMAGE, "-t", "gridfpv-rotorhazard:latest"]) + .arg(&context) + .status() + .expect("run docker build for the RH harness image"); + assert!( + status.success(), + "failed to build {RH_IMAGE} from {} — see the docker build output above", + context.display() + ); +} + /// Block until `port` accepts a TCP connection, or panic after `timeout`. fn wait_for_port(port: u16, timeout: Duration) { let deadline = Instant::now() + timeout; @@ -718,4 +1133,256 @@ mod tests { let csv = plan_csv(&plan); assert_eq!(crossings(&csv), ticks.len()); } + + // --------------------------------------------------------------------------- + // Realistic-signal model: the per-tick RSSI VALUE (col 4 node_peak / col 10 + // dense peak history) is a baseline floor + a Gaussian bell on each crossing, + // with deterministic noise and lap-to-lap variation — not a square wave. + // --------------------------------------------------------------------------- + + /// The per-tick streamed value (col 4 `node_peak`) for every row — what the coarse trace + /// samples and the live sparkline draws. + fn values(csv: &str) -> Vec { + rows(csv) + .iter() + .map(|r| r[4].parse::().expect("node_peak is a number")) + .collect() + } + + /// A compact sparkline (used by the visual sanity test so a human can eyeball the shape). + fn sparkline(vals: &[i32]) -> String { + const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let min = *vals.iter().min().unwrap(); + let max = *vals.iter().max().unwrap(); + let span = (max - min).max(1); + vals.iter() + .map(|&v| BARS[((v - min) as i64 * 7 / span as i64) as usize]) + .collect() + } + + #[test] + fn value_is_bell_not_square() { + // A single node, default density: the value should PEAK at each crossing and sit near the + // baseline midway between crossings — a smooth rise/fall, not a two-level square wave. + let opts = NodeCsv { + ticks_per_lap: 48, + peak_rssi: 200, + baseline_rssi: 60, + seed: 0, + }; + let vals = values(&node_csv(&opts)); + // At a crossing tick (i = 48) the value is near the peak; halfway between (i = 72) it has + // fallen close to the baseline. + let at_cross = vals[48]; + let mid = vals[72]; + assert!( + at_cross > 180, + "value at the crossing should be near the peak (~200), got {at_cross}" + ); + assert!( + mid < 90, + "value midway between crossings should fall near the baseline (~60), got {mid}" + ); + // It is genuinely many-valued (a curve), not just two levels (peak/baseline). + let mut distinct: Vec = vals[24..72].to_vec(); + distinct.sort_unstable(); + distinct.dedup(); + assert!( + distinct.len() > 10, + "the rise→peak→fall should take many distinct values, got {}", + distinct.len() + ); + } + + #[test] + fn baseline_carries_noise() { + // Between crossings the value jitters by a few units (constant small noise) rather than + // being a perfectly flat baseline. + let opts = NodeCsv { + ticks_per_lap: 48, + peak_rssi: 200, + baseline_rssi: 60, + seed: 0, + }; + let vals = values(&node_csv(&opts)); + // Sample the off-gate stretch around mid-lap (i ~ 70..78), away from the bell. + let win = &vals[68..78]; + let min = *win.iter().min().unwrap(); + let max = *win.iter().max().unwrap(); + assert!( + max > min, + "off-gate baseline must jitter (noise), got a flat run: {win:?}" + ); + assert!( + (max - min) <= 12, + "baseline noise should be small (a few units), got spread {} in {win:?}", + max - min + ); + } + + #[test] + fn generation_is_deterministic() { + // Same input -> byte-identical output (seeded RNG, no wall-clock). + let opts = NodeCsv::default(); + assert_eq!(node_csv(&opts), node_csv(&opts)); + let plan = varied_pace(&[40, 60, 50]); + assert_eq!(plan_csv(&plan), plan_csv(&plan)); + } + + #[test] + fn lap_to_lap_peaks_vary() { + // No two passes are identical: the peak height drifts a little lap to lap. + let plan = uniform(6, 60); + let vals = values(&plan_csv(&plan)); + let ticks = plan.crossing_ticks(); + let peaks: Vec = ticks.iter().map(|&t| vals[t]).collect(); + let mut uniq = peaks.clone(); + uniq.sort_unstable(); + uniq.dedup(); + assert!( + uniq.len() > 1, + "lap peaks should vary lap-to-lap, all equal: {peaks:?}" + ); + // But the variation is modest — every pass is still clearly a strong peak. + let min = *peaks.iter().min().unwrap(); + let max = *peaks.iter().max().unwrap(); + assert!( + (max - min) < STRONG_PEAK / 5, + "lap variation should be modest, got {min}..{max}" + ); + } + + #[test] + fn seeded_nodes_differ_in_a_race() { + // Two identical pack plans get distinct traces once `race` keys them by node index. + let out = simultaneous(2, 6, 60); + assert_ne!( + values(&out[0].1), + values(&out[1].1), + "co-racing nodes must have distinct (seeded) noise" + ); + } + + #[test] + fn fidelity_within_model_peak_clears_threshold() { + // The model's fidelity invariant (mirrors the adapter e2e gate): the streamed value (what + // the coarse trace captures) reaches the peak band at each crossing, so RH's enter + // threshold is cleared and laps still record — and every value is a plausible RSSI in + // [baseline-noise, peak+noise]. + const PEAK: i32 = 150; + const BASE: i32 = 70; + let opts = NodeCsv { + ticks_per_lap: 48, + peak_rssi: PEAK, + baseline_rssi: BASE, + seed: 0, + }; + let vals = values(&node_csv(&opts)); + for (i, &v) in vals.iter().enumerate() { + assert!( + (1..=PEAK + 10).contains(&v), + "sample {i}={v} outside the model band [1, peak+noise]" + ); + } + // Each crossing tick clears the enter threshold (so detection fires on a real peak). + for cross in (48..TOTAL_TICKS).step_by(48) { + assert!( + vals[cross] > ENTER_THRESHOLD, + "crossing at {cross} (value {}) must clear the enter threshold {ENTER_THRESHOLD}", + vals[cross] + ); + } + } + + #[test] + fn crest_is_rectified_to_the_nominal_peak() { + // The detection guarantee: at the crossing tick the reported value never dips BELOW the + // lap's (varied) peak — `node_peak`/`pass_peak` are running maxima, so crest noise only adds. + // This is what makes the peak-above-enter margin equal to `peak - enter` with no noise dip + // that could drop a marginal pass under the calibrated threshold. + const PEAK: i32 = 96; // a deliberately low peak so any downward dip would breach the margin + const BASE: i32 = 70; + let opts = NodeCsv { + ticks_per_lap: 48, + peak_rssi: PEAK, + baseline_rssi: BASE, + seed: 7, + }; + let vals = values(&node_csv(&opts)); + for cross in (48..TOTAL_TICKS).step_by(48) { + let nominal = varied_peak(opts.seed, cross / 48, PEAK); + assert!( + vals[cross] >= nominal, + "crest at {cross} ({}) dipped below the lap's nominal peak {nominal} — noise must \ + rectify upward at the crest so detection never loses the margin", + vals[cross] + ); + } + } + + #[test] + fn thin_margin_scenarios_clear_enter_with_headroom() { + // The marginal/noisy/weak scenarios must clear RH's enter threshold at EVERY crossing with a + // real margin (after lap-to-lap variation), so passes record reliably — "marginal" in + // magnitude, never in reliability. (The old +5 marginal could dip to the threshold and miss.) + const MIN_MARGIN: i32 = 8; + for (name, plan) in [ + ("marginal", marginal(8, 48)), + ("noisy", noisy(8, 48)), + ("weak", weak(8, 48)), + ] { + let csv = plan_csv(&plan); + let rows = rows(&csv); + for (i, r) in rows.iter().enumerate() { + if r[7] == "T" { + let v: i32 = r[4].parse().unwrap(); + assert!( + v >= ENTER_THRESHOLD + MIN_MARGIN, + "{name} crossing at {i} (value {v}) must clear enter {ENTER_THRESHOLD} by at \ + least {MIN_MARGIN} so it reliably detects" + ); + } + } + // And the off-gate baseline (sampled midway between crossings, well clear of any bell) + // must stay below the exit threshold (80) so RH's two-state detector cleanly drops out + // between passes and re-arms for the next crossing. Crossings are every 48 ticks; tick + // `n*48 + 24` is the trough between them. + for mid in (24..TOTAL_TICKS).step_by(48) { + let v: i32 = rows[mid][4].parse().unwrap(); + assert!( + v < 80, + "{name} off-gate baseline at {mid} ({v}) must stay below the exit threshold 80" + ); + } + } + } + + #[test] + fn visual_sparkline() { + // Not an assertion gate — run with `--nocapture` to eyeball the shape: + // cargo test -p gridfpv-testkit visual_sparkline -- --nocapture + let clean = node_csv(&NodeCsv { + ticks_per_lap: 48, + peak_rssi: 200, + baseline_rssi: 60, + seed: 0, + }); + println!( + "\nclean (first 144 ticks):\n{}", + &sparkline(&values(&clean)[..144]) + ); + let noisy = plan_csv(&NodePlan { + laps: (0..3) + .map(|_| LapSpec::with_peak(48, MARGINAL_PEAK)) + .collect(), + peak_rssi: MARGINAL_PEAK, + baseline_rssi: 60, + noise_amp: BASE_NOISE * 3.0, + seed: 1, + }); + println!( + "noisy/marginal (first 144 ticks):\n{}", + &sparkline(&values(&noisy)[..144]) + ); + } } diff --git a/docker/rotorhazard/Dockerfile b/docker/rotorhazard/Dockerfile new file mode 100644 index 0000000..8c95006 --- /dev/null +++ b/docker/rotorhazard/Dockerfile @@ -0,0 +1,69 @@ +# GridFPV RotorHazard dev harness — RotorHazard v4.4.0 with mock nodes. +# +# Why build from source: the only published image (cruwaller/rotorhazard:latest) is +# stale at 4.0.0-beta.4 — below the GridFPV plugin's floor of RHAPI 1.3 / RH v4.3.0+ +# (decision D16, docs/rotorhazard-plugin.html). We build the current stable, v4.4.0 +# (RHAPI 1.4), so the harness can exercise the real plugin loader + RHAPI surface. +# +# This supersedes the ad-hoc local `rh-plugin-dev-env-rotorhazard` image (RH 4.3.1, +# source not in-repo): same layout conventions (/opt/RotorHazard, run from +# src/server, MockInterface), made reproducible and pinned to v4.4.0. +# +# Mock nodes: RH auto-selects MockInterface when no timing hardware/serial nodes are +# present (always true in this container). MockInterface reads an emulated per-node +# RSSI stream from mock_data_{N}.csv in its working dir and loops it at EOF, so node +# RSSI/history is non-zero and laps record through RH's genuine detection pipeline. +# A default node-1 stream is baked in; the test harness (crates/testkit RhContainer, +# cargo xtask live/rh-mock) bind-mounts its own per-scenario CSVs over it. +FROM python:3.11-slim + +ARG RH_VERSION=v4.4.0 + +# git to fetch the pinned source; build-essential/libffi as an sdist fallback for the +# native deps (gevent/greenlet/cffi normally resolve to manylinux wheels, but a wheel +# gap on some arches would otherwise fail the build). +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates build-essential libffi-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN git clone --depth 1 --branch "${RH_VERSION}" \ + https://github.com/RotorHazard/RotorHazard.git /opt/RotorHazard + +WORKDIR /opt/RotorHazard/src/server + +# Install the server requirements, minus the Raspberry-Pi-only hardware packages +# (RPi.GPIO / rpi-ws281x / pi-ina219 / RPi.bme280) — they don't build or run on the +# x86 dev host and are irrelevant to mock nodes. The pure-python smbus2 is kept. +RUN grep -viE '^(RPi\.GPIO|rpi-ws281x|pi-ina219|RPi\.bme280)' requirements.txt > /tmp/requirements.harness.txt \ + && pip install --no-cache-dir -r /tmp/requirements.harness.txt + +# RH v4.4.0 added GENERAL.MOCK_NODE_SIGNAL, defaulting to 0 = *no* mock signal. Set it +# to 1 so MockInterface reads mock_data_{N}.csv (the emulated-signal path the GridFPV +# harness depends on; 2 = RH's built-in random signal). Config merges this partial file +# over the defaults. Note: a node only emits signal once it ALSO has a frequency set and +# a mock_data_{N}.csv present — so with no CSV mounted the nodes are quiet (which is what +# the test harness needs; it mounts per-scenario CSVs and stages heats to set channels). +COPY config.json /opt/RotorHazard/src/server/config.json + +# Entrypoint: by default just runs RH (quiet mock nodes — the test-harness contract). In +# demo mode (GRIDFPV_RH_DEMO=1, set by docker-compose) it also tunes any CSV-backed node +# so a bare `docker compose up` shows live signal. No mock_data CSV is baked into the +# image — the demo mounts one via compose; tests mount their own. See the script. +COPY mock-entrypoint.sh /usr/local/bin/mock-entrypoint.sh +RUN chmod +x /usr/local/bin/mock-entrypoint.sh + +# 8 mock nodes; MockInterface's per-tick wall-clock (RH_UPDATE_INTERVAL) — ~67 Hz so a +# lap spans dozens of dense samples. The harness overrides RH_UPDATE_INTERVAL per run. +ENV RH_NODES=8 \ + RH_UPDATE_INTERVAL=0.015 + +EXPOSE 5000 + +HEALTHCHECK --interval=10s --timeout=8s --retries=6 --start-period=20s \ + CMD python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:5000/',timeout=5).status==200 else 1)" + +# The entrypoint runs `server.py --data /opt/RotorHazard/src/server`: pinning DATA_DIR to +# the server dir (= PROGRAM_DIR) makes RH chdir there, so mock_data_{N}.csv and the user +# plugins/ dir both resolve in one place and `plugins.` imports cleanly off sys.path. +# (Without --data, v4.4.0 chdir's to ~/rh-data and the mounts would miss.) +CMD ["/usr/local/bin/mock-entrypoint.sh"] diff --git a/docker/rotorhazard/README.md b/docker/rotorhazard/README.md index 5baff06..7ea1717 100644 --- a/docker/rotorhazard/README.md +++ b/docker/rotorhazard/README.md @@ -1,58 +1,103 @@ -# Dockerized RotorHazard (#25) +# Dockerized RotorHazard dev harness (#25, D16) -A self-hosted, disposable RotorHazard instance for capturing fixtures and live -integration testing of the RotorHazard adapter (`crates/adapters/src/rotorhazard.rs`). -It runs with 8 **simulated (mock) nodes**, and `simulate_lap` over Socket.IO -produces real pass/lap records — so we get genuine wire frames without hardware. +A self-hosted, disposable RotorHazard instance we fully control — for capturing +fixtures, live integration testing of the RotorHazard adapter +(`crates/adapters/src/rotorhazard/`), and **iterating on the GridFPV RH plugin** +(decision D16; design in `docs/rotorhazard-plugin.html`). -Per `docs/testing-strategy.html`, a dockerized RH we fully control is an -*allowed* live-test dependency; the banned dependency is shared/production -backends (e.g. the Velocidrone game servers). +It runs with 8 **simulated (mock) nodes**, and an emulated-signal `mock_data` CSV so +RH's *real* lap pipeline produces passes (genuine wire frames without hardware). + +Per `docs/testing-strategy.html`, a dockerized RH we fully control is an *allowed* +live-test dependency; the banned dependency is shared/production backends. + +## Built from source at RotorHazard v4.4.0 + +The image is **built from RotorHazard v4.4.0 source** (`Dockerfile`), not pulled. +This is deliberate: the only published image (`cruwaller/rotorhazard:latest`) is stale +at **4.0.0-beta.4**, below the GridFPV plugin's floor of **RHAPI 1.3 / RH v4.3.0+** +(D16). v4.4.0 (RHAPI 1.4) lets the harness exercise the real plugin loader + RHAPI +surface. (This supersedes the ad-hoc local `rh-plugin-dev-env-rotorhazard` image, +RH 4.3.1, whose source was not in-repo — same conventions, made reproducible.) + +Two harness specifics, both load-bearing on v4.4.0: + +- **`MOCK_NODE_SIGNAL=1`** (baked `config.json`): v4.4.0 added this option, defaulting + to `0` = *no* mock signal. `1` makes `MockInterface` read `mock_data_{N}.csv` (the + emulated-signal path; `2` = RH's built-in random signal). Without it, mock nodes + report `current_rssi = 0` and the whole emulated-signal harness goes dark. +- **`--data /opt/RotorHazard/src/server`**: pins `DATA_DIR` to the server dir so + `mock_data_{N}.csv` and the user `plugins/` dir resolve in one place and + `plugins.` imports cleanly. (Without it, v4.4.0 `chdir`s to `~/rh-data` and the + mounts miss.) The entrypoint then tunes the CSV-backed node(s) to a channel so signal + flows immediately on a bare run. ## Run it ```sh -docker compose -f docker/rotorhazard/docker-compose.yml up -d -# server comes up on http://localhost:5000 (HTTP + Socket.IO) +docker compose -f docker/rotorhazard/docker-compose.yml up -d --build +# server comes up on http://localhost:5000 (HTTP + Socket.IO), node 1 streaming signal +docker compose -f docker/rotorhazard/docker-compose.yml down ``` +The test harness (`cargo xtask live` / `rh-mock`) builds the image automatically the +first time via the testkit's `RhContainer` — no manual build needed for tests. + +## GridFPV plugin (D16) + +The plugin lives at `plugins/gridfpv/` (repo root). The harness mounts it into the +container's user `plugins/gridfpv` and boots it: + +```sh +cargo xtask rh-mock plugin-check # boot RH + plugin, confirm it loads cleanly +cargo xtask rh-mock feed clean --plugin # interactive: a live timer WITH the plugin +cargo xtask live # every live RH boots against the plugin +``` + +`plugin-check` is the S0 acceptance test: it asserts RH's loader reports the plugin +**loaded** with no `load_issue`. To live-edit the plugin under `docker compose`, +uncomment the bind mount in `docker-compose.yml` and restart RH. + ## Capture frames -`capture_frames.py` connects over Socket.IO, stages a race, simulates laps on two -nodes, and dumps every captured frame (`race_status`, `current_laps`, -`pass_record`, `node_data`, `leaderboard`) as JSON: +`capture_frames.py` connects over Socket.IO, stages a race, simulates laps, and dumps +every captured frame (`race_status`, `current_laps`, `pass_record`, `node_data`, +`leaderboard`) as JSON: ```sh -# easiest: run inside the container's venv (has python-socketio) -docker exec gridfpv-rh /root/venv_rh/bin/python3 /tmp/capture_frames.py -# (docker cp docker/rotorhazard/capture_frames.py gridfpv-rh:/tmp/ first) +docker cp docker/rotorhazard/capture_frames.py gridfpv-rh:/tmp/ +docker exec gridfpv-rh python3 /tmp/capture_frames.py ``` A trimmed real capture is checked in at -`crates/adapters/src/rotorhazard/fixtures/captured-mock-race.json` and is the -ground truth the adapter + its golden replay test are validated against. +`crates/adapters/src/rotorhazard/fixtures/captured-mock-race.json` and is the ground +truth the adapter + its golden replay test are validated against. -## Real wire format (validated 2026-06 against this image) +## Real wire format (validated against this image) -- `race_status` carries an integer `race_status`: **3 = staging, 1 = racing, - 2 = done** (0 = ready). +- `race_status` carries an integer `race_status`: **3 = staging, 1 = racing, 2 = done** + (0 = ready). - `current_laps` is a **snapshot**: `{ "current": { "node_index": [ {laps:[…]}, … ] } }`, - one array entry per node (array index = node index). Each lap has - `lap_index, lap_number, lap_raw (ms), lap_time ("M:SS.mmm" string), - lap_time_stamp (cumulative ms since race start), splits, late_lap`. - It does **not** carry `source`, `deleted`, or `peak_rssi`, and deleted laps are - already filtered out. -- `pass_record` fires per crossing: `{ node, frequency, timestamp }` where - `timestamp` is epoch-milliseconds. -- `node_data` carries per-node `pass_peak_rssi[]` (and node/nadir variants) — - this is where per-pass RSSI comes from (0 under mock nodes). + one array entry per node (array index = node index). The lap-time/deletion shape + **changed across RH versions**: on RH ≤ 4.0 the duration was `lap_raw` (ms) with + `lap_time` a `"M:SS.mmm"` *string* and deleted laps pre-filtered; on **RH 4.3+/4.4** + `lap_time` is a *numeric* ms duration (the string moved to `lap_time_formatted`) and + laps carry `source` + `deleted` inline. Stable across both: `lap_index, lap_number, + lap_time_stamp (cumulative ms since race start), splits, late_lap`. The adapter reads + only `lap_number` + `lap_time_stamp`, parses `lap_time` permissively, and skips + `deleted` laps (`crates/adapters/src/rotorhazard.rs`). +- `pass_record` fires per crossing: `{ node, frequency, timestamp }` where `timestamp` + is epoch-milliseconds. +- `node_data` / heartbeat carry per-node RSSI (`current_rssi`, `pass_peak_rssi`, …). + With `MOCK_NODE_SIGNAL=1` + a `mock_data` CSV these are **non-zero** (the emulated + signal), unlike bare mock nodes which report 0. ## Live & emulated-signal tests The RotorHazard adapter has a live Socket.IO transport (`crates/adapters`, feature -`live`). These are a **local-only** test class — container-dependent tests don't run -in the shared CI pipeline. Each test spins up and tears down its own disposable RH, -so `cargo xtask live` is the one command you need (Docker required): +`live`). These are a **local-only** test class — container-dependent tests don't run in +the shared CI pipeline. Each test spins up and tears down its own disposable RH, so +`cargo xtask live` is the one command you need (Docker required): ```sh cargo xtask live # runs all live targets, one container at a time @@ -70,24 +115,26 @@ signal detection. Exercises the live Socket.IO transport + lap recording + our a Drives RH from **emulated node-output streams** so its *real* lap pipeline produces the passes. RotorHazard's mock interface reads a per-node `mock_data_{N}.csv` — one CSV row per tick (`RH_UPDATE_INTERVAL`, set low to speed replay) — and records a lap each time -the row's `lap_id` column increments. A small generator (`tests/common/mod.rs::node_csv`) -turns a scenario (lap cadence, peak RSSI, active/silent node) into the CSVs; `RhContainer` -mounts them and runs the race. This tests the full chain: emulated signal → RH -detection/recording → Socket.IO → adapter → projection, including signal context. +the row's `lap_id` column increments. A small generator +(`gridfpv_testkit::node_csv`) turns a scenario (lap cadence, peak RSSI, active/silent +node) into the CSVs; `RhContainer` mounts them and runs the race. This tests the full +chain: emulated signal → RH detection/recording → Socket.IO → adapter → projection, +including signal context. > **mock_data CSV gotcha:** the mock reads its file continuously from container start, > decoupled from race start — so `lap_id` must increment **throughout** the file (a > capped `lap_id` stops producing laps before the race begins). Exact lap timing is > therefore approximate; assert structure (lap counts, signal magnitude, dedup), not µs. -This emulated-signal harness is also what we'll use to test timing-dependent **race +This emulated-signal harness is also what we use to test timing-dependent **race engine** features (heat loop, scoring, marshaling, advancement) end to end — see `docs/testing-strategy.html` §5.1. ### Manual ```sh -docker compose -f docker/rotorhazard/docker-compose.yml up -d --wait -python docker/rotorhazard/capture_frames.py # or poke the Socket.IO API yourself +docker compose -f docker/rotorhazard/docker-compose.yml up -d --build --wait +docker cp docker/rotorhazard/capture_frames.py gridfpv-rh:/tmp/ && \ + docker exec gridfpv-rh python3 /tmp/capture_frames.py docker compose -f docker/rotorhazard/docker-compose.yml down ``` diff --git a/docker/rotorhazard/config.json b/docker/rotorhazard/config.json new file mode 100644 index 0000000..f8f72a5 --- /dev/null +++ b/docker/rotorhazard/config.json @@ -0,0 +1,5 @@ +{ + "GENERAL": { + "MOCK_NODE_SIGNAL": 1 + } +} diff --git a/docker/rotorhazard/docker-compose.yml b/docker/rotorhazard/docker-compose.yml index 02d26c5..23ee139 100644 --- a/docker/rotorhazard/docker-compose.yml +++ b/docker/rotorhazard/docker-compose.yml @@ -1,22 +1,37 @@ -# Dockerized RotorHazard — a self-hosted, disposable instance we fully control, -# for capturing fixtures and (later) live integration tests (#25). +# GridFPV RotorHazard dev harness — a self-hosted, disposable RH we fully control, +# for capturing fixtures, live integration tests (#25), and iterating on the GridFPV +# RH plugin (D16, docs/rotorhazard-plugin.html). # -# RotorHazard boots with 8 simulated (mock) nodes; `simulate_lap` over Socket.IO -# generates real pass/lap records. The container is the only "live timer" our -# tests are allowed to depend on (the banned dependency is shared/production -# backends like the Velocidrone game servers — see docs/testing-strategy.html). +# Built from source at RotorHazard v4.4.0 (see Dockerfile) — the floor for the GridFPV +# plugin (RHAPI 1.3 / RH v4.3.0+). It boots 8 simulated (mock) nodes; MockInterface +# reads an emulated RSSI stream from mock_data_{N}.csv (MOCK_NODE_SIGNAL=1), so node +# RSSI/history is non-zero and laps record through RH's genuine detection pipeline. # -# docker compose -f docker/rotorhazard/docker-compose.yml up -d -# python docker/rotorhazard/capture_frames.py # drives a race, dumps frames +# docker compose -f docker/rotorhazard/docker-compose.yml up -d --build +# # server + Socket.IO on http://localhost:5000 # docker compose -f docker/rotorhazard/docker-compose.yml down +# +# To iterate on the plugin, uncomment the bind mount below (or use the test harness: +# `cargo xtask rh-mock feed --plugin`, `cargo xtask rh-mock plugin-check`). services: rotorhazard: - image: cruwaller/rotorhazard:latest + build: + context: . + image: gridfpv-rotorhazard:4.4.0 container_name: gridfpv-rh ports: - "5000:5000" # HTTP + Socket.IO - # Mock nodes are the default when no hardware is attached; no extra config - # needed. The server logs "Server is using simulated (mock) nodes". + environment: + # Demo mode: after boot, tune the CSV-backed node so a bare `compose up` shows a + # live RSSI feed without staging a heat. (The test harness leaves this unset, so + # its mock nodes stay quiet until it stages heats — see mock-entrypoint.sh.) + GRIDFPV_RH_DEMO: "1" + volumes: + # The emulated-signal stream for node 1 (not baked into the image — mounted here so + # the standalone rig demonstrates non-zero RSSI; the test harness mounts its own). + - ./mock_data_1.csv:/opt/RotorHazard/src/server/mock_data_1.csv:ro + # Live-edit the GridFPV plugin inside the running server (RH restart picks up changes): + # - ../../plugins/gridfpv:/opt/RotorHazard/src/server/plugins/gridfpv:ro healthcheck: test: ["CMD", "python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:5000/',timeout=5).status==200 else 1)"] interval: 10s diff --git a/docker/rotorhazard/mock-entrypoint.sh b/docker/rotorhazard/mock-entrypoint.sh new file mode 100644 index 0000000..3855b09 --- /dev/null +++ b/docker/rotorhazard/mock-entrypoint.sh @@ -0,0 +1,60 @@ +#!/bin/sh +# GridFPV RH dev-harness entrypoint. +# +# Default (test harness): just run RotorHazard. Mock nodes stay quiet (frequency 0, +# no mock_data CSV mounted) until something tunes them — exactly like a fresh RH. This +# is what the `cargo xtask live` / testkit flows need: a node only emits signal once a +# heat is staged (which sets its frequency) AND a mock_data_{N}.csv is mounted for it. +# Anything that streams signal *before* the test stages its heat corrupts lap detection +# (spurious/again pre-race crossings), so the harness must boot a quiet server. +# +# Demo mode (GRIDFPV_RH_DEMO=1, set by docker-compose): after boot, tune every mock +# node that has a mock_data_{N}.csv to a channel, so a bare `docker compose up` shows a +# live, non-zero RSSI feed without anyone staging a heat. Never set in the test harness. +set -eu + +SERVER_DIR=/opt/RotorHazard/src/server +cd "$SERVER_DIR" + +if [ "${GRIDFPV_RH_DEMO:-0}" != "1" ]; then + # Test-harness / plain path: hand PID 1 to RotorHazard (clean signals, no extras). + exec python3 server.py --data "$SERVER_DIR" +fi + +# --- Demo mode only below --- +python3 server.py --data "$SERVER_DIR" & +RH_PID=$! +trap 'kill -TERM "$RH_PID" 2>/dev/null || true; wait "$RH_PID" 2>/dev/null || true; exit 0' TERM INT + +# One-shot tuner: wait for the socket.io server, then set a channel on each CSV-backed +# node. Failures are non-fatal — the rig still runs, just without the head-start signal. +python3 - <<'PY' || echo "mock-entrypoint: demo tuner skipped (RH up regardless)" +import glob, os, time +import socketio # ships with RotorHazard's requirements + +# Raceband R1..R8 — one channel per node; only CSV-backed nodes actually emit signal. +RACEBAND = [5658, 5695, 5732, 5769, 5806, 5843, 5880, 5917] +nodes = sorted(int(os.path.basename(p).split("_")[2].split(".")[0]) - 1 + for p in glob.glob("mock_data_*.csv")) +if not nodes: + raise SystemExit(0) + +sio = socketio.Client(reconnection=True, reconnection_attempts=30) +for _ in range(60): + try: + sio.connect("http://localhost:5000", wait_timeout=5) + break + except Exception: + time.sleep(1) +else: + raise SystemExit("could not reach RH socket.io to tune mock nodes") + +for n in nodes: + sio.emit("set_frequency", {"node": n, "frequency": RACEBAND[n % len(RACEBAND)]}) + time.sleep(0.1) +time.sleep(0.5) +sio.disconnect() +print("mock-entrypoint: demo-tuned nodes %s to raceband channels" % [n + 1 for n in nodes]) +PY + +wait "$RH_PID" diff --git a/docker/rotorhazard/mock_data_1.csv b/docker/rotorhazard/mock_data_1.csv new file mode 100644 index 0000000..f98e95d --- /dev/null +++ b/docker/rotorhazard/mock_data_1.csv @@ -0,0 +1,600 @@ +0,0,0,150,150,149,10,F,20,30,150,2,1,70,2,1 +0,0,0,149,149,149,10,F,20,30,149,2,1,70,2,1 +0,0,0,147,147,149,10,F,20,30,147,2,1,70,2,1 +0,0,0,144,144,149,10,F,20,30,144,2,1,70,2,1 +0,0,0,140,140,149,10,F,20,30,140,2,1,70,2,1 +0,0,0,134,134,149,10,F,20,30,134,2,1,70,2,1 +0,0,0,129,129,149,10,F,20,30,129,2,1,70,2,1 +0,0,0,123,123,149,10,F,20,30,123,2,1,70,2,1 +0,0,0,117,117,149,10,F,20,30,117,2,1,70,2,1 +0,0,0,109,109,149,10,F,20,30,109,2,1,70,2,1 +0,0,0,104,104,149,10,F,20,30,104,2,1,70,2,1 +0,0,0,98,98,149,10,F,20,30,98,2,1,70,2,1 +0,0,0,94,94,149,10,F,20,30,94,2,1,70,2,1 +0,0,0,90,90,149,10,F,20,30,90,2,1,70,2,1 +0,0,0,86,86,149,10,F,20,30,86,2,1,70,2,1 +0,0,0,81,81,149,10,F,20,30,81,2,1,70,2,1 +0,0,0,78,78,149,10,F,20,30,78,2,1,70,2,1 +0,0,0,77,77,149,10,F,20,30,77,2,1,70,2,1 +0,0,0,77,77,149,10,F,20,30,77,2,1,70,2,1 +0,0,0,75,75,149,10,F,20,30,75,2,1,70,2,1 +0,0,0,71,71,149,10,F,20,30,71,2,1,70,2,1 +0,0,0,70,70,149,10,F,20,30,70,2,1,70,2,1 +0,0,0,72,72,149,10,F,20,30,72,2,1,70,2,1 +0,0,0,70,70,149,10,F,20,30,70,2,1,70,2,1 +0,0,0,69,69,149,10,F,20,30,69,2,1,70,2,1 +0,0,0,71,71,149,10,F,20,30,71,2,1,70,2,1 +0,0,0,69,69,149,10,F,20,30,69,2,1,70,2,1 +0,0,0,74,74,149,10,F,20,30,74,2,1,70,2,1 +0,0,0,71,71,149,10,F,20,30,71,2,1,70,2,1 +0,0,0,75,75,149,10,F,20,30,75,2,1,70,2,1 +0,0,0,74,74,149,10,F,20,30,74,2,1,70,2,1 +0,0,0,79,79,149,10,F,20,30,79,2,1,70,2,1 +0,0,0,80,80,149,10,F,20,30,80,2,1,70,2,1 +0,0,0,81,81,149,10,F,20,30,81,2,1,70,2,1 +0,0,0,84,84,149,10,F,20,30,84,2,1,70,2,1 +0,0,0,88,88,149,10,F,20,30,88,2,1,70,2,1 +0,0,0,92,92,149,10,F,20,30,92,2,1,70,2,1 +0,0,0,99,99,149,10,F,20,30,99,2,1,70,2,1 +0,0,0,106,106,149,10,F,20,30,106,2,1,70,2,1 +0,0,0,111,111,149,10,F,20,30,111,2,1,70,2,1 +0,0,0,116,116,149,10,F,20,30,116,2,1,70,2,1 +0,0,0,122,122,149,10,F,20,30,122,2,1,70,2,1 +0,0,0,129,129,149,10,F,20,30,129,2,1,70,2,1 +0,0,0,134,134,149,10,F,20,30,134,2,1,70,2,1 +0,0,0,140,140,149,10,F,20,30,140,2,1,70,2,1 +0,0,0,144,144,149,10,F,20,30,144,2,1,70,2,1 +0,0,0,147,147,149,10,F,20,30,147,2,1,70,2,1 +0,0,0,149,149,149,10,F,20,30,149,2,1,70,2,1 +0,1,0,149,149,149,10,T,20,30,149,2,1,70,2,1 +0,1,0,149,149,149,10,F,20,30,149,2,1,70,2,1 +0,1,0,147,147,149,10,F,20,30,147,2,1,70,2,1 +0,1,0,144,144,149,10,F,20,30,144,2,1,70,2,1 +0,1,0,139,139,149,10,F,20,30,139,2,1,70,2,1 +0,1,0,134,134,149,10,F,20,30,134,2,1,70,2,1 +0,1,0,128,128,149,10,F,20,30,128,2,1,70,2,1 +0,1,0,123,123,149,10,F,20,30,123,2,1,70,2,1 +0,1,0,115,115,149,10,F,20,30,115,2,1,70,2,1 +0,1,0,109,109,149,10,F,20,30,109,2,1,70,2,1 +0,1,0,104,104,149,10,F,20,30,104,2,1,70,2,1 +0,1,0,97,97,149,10,F,20,30,97,2,1,70,2,1 +0,1,0,94,94,149,10,F,20,30,94,2,1,70,2,1 +0,1,0,87,87,149,10,F,20,30,87,2,1,70,2,1 +0,1,0,84,84,149,10,F,20,30,84,2,1,70,2,1 +0,1,0,80,80,149,10,F,20,30,80,2,1,70,2,1 +0,1,0,80,80,149,10,F,20,30,80,2,1,70,2,1 +0,1,0,78,78,149,10,F,20,30,78,2,1,70,2,1 +0,1,0,76,76,149,10,F,20,30,76,2,1,70,2,1 +0,1,0,74,74,149,10,F,20,30,74,2,1,70,2,1 +0,1,0,74,74,149,10,F,20,30,74,2,1,70,2,1 +0,1,0,70,70,149,10,F,20,30,70,2,1,70,2,1 +0,1,0,74,74,149,10,F,20,30,74,2,1,70,2,1 +0,1,0,70,70,149,10,F,20,30,70,2,1,70,2,1 +0,1,0,70,70,152,10,F,20,30,70,2,1,70,2,1 +0,1,0,74,74,152,10,F,20,30,74,2,1,70,2,1 +0,1,0,71,71,152,10,F,20,30,71,2,1,70,2,1 +0,1,0,73,73,152,10,F,20,30,73,2,1,70,2,1 +0,1,0,73,73,152,10,F,20,30,73,2,1,70,2,1 +0,1,0,73,73,152,10,F,20,30,73,2,1,70,2,1 +0,1,0,74,74,152,10,F,20,30,74,2,1,70,2,1 +0,1,0,77,77,152,10,F,20,30,77,2,1,70,2,1 +0,1,0,77,77,152,10,F,20,30,77,2,1,70,2,1 +0,1,0,83,83,152,10,F,20,30,83,2,1,70,2,1 +0,1,0,85,85,152,10,F,20,30,85,2,1,70,2,1 +0,1,0,91,91,152,10,F,20,30,91,2,1,70,2,1 +0,1,0,94,94,152,10,F,20,30,94,2,1,70,2,1 +0,1,0,99,99,152,10,F,20,30,99,2,1,70,2,1 +0,1,0,106,106,152,10,F,20,30,106,2,1,70,2,1 +0,1,0,112,112,152,10,F,20,30,112,2,1,70,2,1 +0,1,0,117,117,152,10,F,20,30,117,2,1,70,2,1 +0,1,0,124,124,152,10,F,20,30,124,2,1,70,2,1 +0,1,0,131,131,152,10,F,20,30,131,2,1,70,2,1 +0,1,0,137,137,152,10,F,20,30,137,2,1,70,2,1 +0,1,0,142,142,152,10,F,20,30,142,2,1,70,2,1 +0,1,0,146,146,152,10,F,20,30,146,2,1,70,2,1 +0,1,0,150,150,152,10,F,20,30,150,2,1,70,2,1 +0,1,0,152,152,152,10,F,20,30,152,2,1,70,2,1 +0,2,0,153,153,152,10,T,20,30,153,2,1,70,2,1 +0,2,0,152,152,152,10,F,20,30,152,2,1,70,2,1 +0,2,0,149,149,152,10,F,20,30,149,2,1,70,2,1 +0,2,0,146,146,152,10,F,20,30,146,2,1,70,2,1 +0,2,0,143,143,152,10,F,20,30,143,2,1,70,2,1 +0,2,0,137,137,152,10,F,20,30,137,2,1,70,2,1 +0,2,0,130,130,152,10,F,20,30,130,2,1,70,2,1 +0,2,0,124,124,152,10,F,20,30,124,2,1,70,2,1 +0,2,0,117,117,152,10,F,20,30,117,2,1,70,2,1 +0,2,0,113,113,152,10,F,20,30,113,2,1,70,2,1 +0,2,0,105,105,152,10,F,20,30,105,2,1,70,2,1 +0,2,0,101,101,152,10,F,20,30,101,2,1,70,2,1 +0,2,0,92,92,152,10,F,20,30,92,2,1,70,2,1 +0,2,0,89,89,152,10,F,20,30,89,2,1,70,2,1 +0,2,0,86,86,152,10,F,20,30,86,2,1,70,2,1 +0,2,0,81,81,152,10,F,20,30,81,2,1,70,2,1 +0,2,0,79,79,152,10,F,20,30,79,2,1,70,2,1 +0,2,0,75,75,152,10,F,20,30,75,2,1,70,2,1 +0,2,0,74,74,152,10,F,20,30,74,2,1,70,2,1 +0,2,0,76,76,152,10,F,20,30,76,2,1,70,2,1 +0,2,0,72,72,152,10,F,20,30,72,2,1,70,2,1 +0,2,0,75,75,152,10,F,20,30,75,2,1,70,2,1 +0,2,0,69,69,152,10,F,20,30,69,2,1,70,2,1 +0,2,0,70,70,152,10,F,20,30,70,2,1,70,2,1 +0,2,0,70,70,149,10,F,20,30,70,2,1,70,2,1 +0,2,0,73,73,149,10,F,20,30,73,2,1,70,2,1 +0,2,0,73,73,149,10,F,20,30,73,2,1,70,2,1 +0,2,0,74,74,149,10,F,20,30,74,2,1,70,2,1 +0,2,0,75,75,149,10,F,20,30,75,2,1,70,2,1 +0,2,0,76,76,149,10,F,20,30,76,2,1,70,2,1 +0,2,0,76,76,149,10,F,20,30,76,2,1,70,2,1 +0,2,0,78,78,149,10,F,20,30,78,2,1,70,2,1 +0,2,0,80,80,149,10,F,20,30,80,2,1,70,2,1 +0,2,0,80,80,149,10,F,20,30,80,2,1,70,2,1 +0,2,0,84,84,149,10,F,20,30,84,2,1,70,2,1 +0,2,0,90,90,149,10,F,20,30,90,2,1,70,2,1 +0,2,0,95,95,149,10,F,20,30,95,2,1,70,2,1 +0,2,0,98,98,149,10,F,20,30,98,2,1,70,2,1 +0,2,0,103,103,149,10,F,20,30,103,2,1,70,2,1 +0,2,0,108,108,149,10,F,20,30,108,2,1,70,2,1 +0,2,0,118,118,149,10,F,20,30,118,2,1,70,2,1 +0,2,0,122,122,149,10,F,20,30,122,2,1,70,2,1 +0,2,0,129,129,149,10,F,20,30,129,2,1,70,2,1 +0,2,0,134,134,149,10,F,20,30,134,2,1,70,2,1 +0,2,0,140,140,149,10,F,20,30,140,2,1,70,2,1 +0,2,0,144,144,149,10,F,20,30,144,2,1,70,2,1 +0,2,0,147,147,149,10,F,20,30,147,2,1,70,2,1 +0,2,0,149,149,149,10,F,20,30,149,2,1,70,2,1 +0,3,0,150,150,149,10,T,20,30,150,2,1,70,2,1 +0,3,0,150,150,149,10,F,20,30,150,2,1,70,2,1 +0,3,0,147,147,149,10,F,20,30,147,2,1,70,2,1 +0,3,0,144,144,149,10,F,20,30,144,2,1,70,2,1 +0,3,0,140,140,149,10,F,20,30,140,2,1,70,2,1 +0,3,0,135,135,149,10,F,20,30,135,2,1,70,2,1 +0,3,0,129,129,149,10,F,20,30,129,2,1,70,2,1 +0,3,0,122,122,149,10,F,20,30,122,2,1,70,2,1 +0,3,0,115,115,149,10,F,20,30,115,2,1,70,2,1 +0,3,0,110,110,149,10,F,20,30,110,2,1,70,2,1 +0,3,0,103,103,149,10,F,20,30,103,2,1,70,2,1 +0,3,0,97,97,149,10,F,20,30,97,2,1,70,2,1 +0,3,0,94,94,149,10,F,20,30,94,2,1,70,2,1 +0,3,0,91,91,149,10,F,20,30,91,2,1,70,2,1 +0,3,0,85,85,149,10,F,20,30,85,2,1,70,2,1 +0,3,0,80,80,149,10,F,20,30,80,2,1,70,2,1 +0,3,0,78,78,149,10,F,20,30,78,2,1,70,2,1 +0,3,0,79,79,149,10,F,20,30,79,2,1,70,2,1 +0,3,0,73,73,149,10,F,20,30,73,2,1,70,2,1 +0,3,0,75,75,149,10,F,20,30,75,2,1,70,2,1 +0,3,0,70,70,149,10,F,20,30,70,2,1,70,2,1 +0,3,0,73,73,149,10,F,20,30,73,2,1,70,2,1 +0,3,0,71,71,149,10,F,20,30,71,2,1,70,2,1 +0,3,0,73,73,149,10,F,20,30,73,2,1,70,2,1 +0,3,0,73,73,152,10,F,20,30,73,2,1,70,2,1 +0,3,0,70,70,152,10,F,20,30,70,2,1,70,2,1 +0,3,0,71,71,152,10,F,20,30,71,2,1,70,2,1 +0,3,0,69,69,152,10,F,20,30,69,2,1,70,2,1 +0,3,0,74,74,152,10,F,20,30,74,2,1,70,2,1 +0,3,0,72,72,152,10,F,20,30,72,2,1,70,2,1 +0,3,0,74,74,152,10,F,20,30,74,2,1,70,2,1 +0,3,0,79,79,152,10,F,20,30,79,2,1,70,2,1 +0,3,0,80,80,152,10,F,20,30,80,2,1,70,2,1 +0,3,0,82,82,152,10,F,20,30,82,2,1,70,2,1 +0,3,0,84,84,152,10,F,20,30,84,2,1,70,2,1 +0,3,0,92,92,152,10,F,20,30,92,2,1,70,2,1 +0,3,0,93,93,152,10,F,20,30,93,2,1,70,2,1 +0,3,0,101,101,152,10,F,20,30,101,2,1,70,2,1 +0,3,0,106,106,152,10,F,20,30,106,2,1,70,2,1 +0,3,0,111,111,152,10,F,20,30,111,2,1,70,2,1 +0,3,0,118,118,152,10,F,20,30,118,2,1,70,2,1 +0,3,0,124,124,152,10,F,20,30,124,2,1,70,2,1 +0,3,0,131,131,152,10,F,20,30,131,2,1,70,2,1 +0,3,0,136,136,152,10,F,20,30,136,2,1,70,2,1 +0,3,0,142,142,152,10,F,20,30,142,2,1,70,2,1 +0,3,0,147,147,152,10,F,20,30,147,2,1,70,2,1 +0,3,0,150,150,152,10,F,20,30,150,2,1,70,2,1 +0,3,0,152,152,152,10,F,20,30,152,2,1,70,2,1 +0,4,0,153,153,152,10,T,20,30,153,2,1,70,2,1 +0,4,0,152,152,152,10,F,20,30,152,2,1,70,2,1 +0,4,0,149,149,152,10,F,20,30,149,2,1,70,2,1 +0,4,0,147,147,152,10,F,20,30,147,2,1,70,2,1 +0,4,0,143,143,152,10,F,20,30,143,2,1,70,2,1 +0,4,0,136,136,152,10,F,20,30,136,2,1,70,2,1 +0,4,0,130,130,152,10,F,20,30,130,2,1,70,2,1 +0,4,0,124,124,152,10,F,20,30,124,2,1,70,2,1 +0,4,0,119,119,152,10,F,20,30,119,2,1,70,2,1 +0,4,0,112,112,152,10,F,20,30,112,2,1,70,2,1 +0,4,0,107,107,152,10,F,20,30,107,2,1,70,2,1 +0,4,0,101,101,152,10,F,20,30,101,2,1,70,2,1 +0,4,0,95,95,152,10,F,20,30,95,2,1,70,2,1 +0,4,0,92,92,152,10,F,20,30,92,2,1,70,2,1 +0,4,0,83,83,152,10,F,20,30,83,2,1,70,2,1 +0,4,0,80,80,152,10,F,20,30,80,2,1,70,2,1 +0,4,0,80,80,152,10,F,20,30,80,2,1,70,2,1 +0,4,0,79,79,152,10,F,20,30,79,2,1,70,2,1 +0,4,0,75,75,152,10,F,20,30,75,2,1,70,2,1 +0,4,0,73,73,152,10,F,20,30,73,2,1,70,2,1 +0,4,0,73,73,152,10,F,20,30,73,2,1,70,2,1 +0,4,0,74,74,152,10,F,20,30,74,2,1,70,2,1 +0,4,0,69,69,152,10,F,20,30,69,2,1,70,2,1 +0,4,0,69,69,152,10,F,20,30,69,2,1,70,2,1 +0,4,0,69,69,146,10,F,20,30,69,2,1,70,2,1 +0,4,0,74,74,146,10,F,20,30,74,2,1,70,2,1 +0,4,0,71,71,146,10,F,20,30,71,2,1,70,2,1 +0,4,0,69,69,146,10,F,20,30,69,2,1,70,2,1 +0,4,0,71,71,146,10,F,20,30,71,2,1,70,2,1 +0,4,0,75,75,146,10,F,20,30,75,2,1,70,2,1 +0,4,0,73,73,146,10,F,20,30,73,2,1,70,2,1 +0,4,0,77,77,146,10,F,20,30,77,2,1,70,2,1 +0,4,0,78,78,146,10,F,20,30,78,2,1,70,2,1 +0,4,0,84,84,146,10,F,20,30,84,2,1,70,2,1 +0,4,0,87,87,146,10,F,20,30,87,2,1,70,2,1 +0,4,0,90,90,146,10,F,20,30,90,2,1,70,2,1 +0,4,0,91,91,146,10,F,20,30,91,2,1,70,2,1 +0,4,0,98,98,146,10,F,20,30,98,2,1,70,2,1 +0,4,0,105,105,146,10,F,20,30,105,2,1,70,2,1 +0,4,0,109,109,146,10,F,20,30,109,2,1,70,2,1 +0,4,0,115,115,146,10,F,20,30,115,2,1,70,2,1 +0,4,0,121,121,146,10,F,20,30,121,2,1,70,2,1 +0,4,0,127,127,146,10,F,20,30,127,2,1,70,2,1 +0,4,0,132,132,146,10,F,20,30,132,2,1,70,2,1 +0,4,0,137,137,146,10,F,20,30,137,2,1,70,2,1 +0,4,0,141,141,146,10,F,20,30,141,2,1,70,2,1 +0,4,0,144,144,146,10,F,20,30,144,2,1,70,2,1 +0,4,0,146,146,146,10,F,20,30,146,2,1,70,2,1 +0,5,0,146,146,146,10,T,20,30,146,2,1,70,2,1 +0,5,0,146,146,146,10,F,20,30,146,2,1,70,2,1 +0,5,0,144,144,146,10,F,20,30,144,2,1,70,2,1 +0,5,0,141,141,146,10,F,20,30,141,2,1,70,2,1 +0,5,0,137,137,146,10,F,20,30,137,2,1,70,2,1 +0,5,0,133,133,146,10,F,20,30,133,2,1,70,2,1 +0,5,0,126,126,146,10,F,20,30,126,2,1,70,2,1 +0,5,0,120,120,146,10,F,20,30,120,2,1,70,2,1 +0,5,0,113,113,146,10,F,20,30,113,2,1,70,2,1 +0,5,0,110,110,146,10,F,20,30,110,2,1,70,2,1 +0,5,0,101,101,146,10,F,20,30,101,2,1,70,2,1 +0,5,0,96,96,146,10,F,20,30,96,2,1,70,2,1 +0,5,0,93,93,146,10,F,20,30,93,2,1,70,2,1 +0,5,0,89,89,146,10,F,20,30,89,2,1,70,2,1 +0,5,0,84,84,146,10,F,20,30,84,2,1,70,2,1 +0,5,0,80,80,146,10,F,20,30,80,2,1,70,2,1 +0,5,0,80,80,146,10,F,20,30,80,2,1,70,2,1 +0,5,0,76,76,146,10,F,20,30,76,2,1,70,2,1 +0,5,0,78,78,146,10,F,20,30,78,2,1,70,2,1 +0,5,0,74,74,146,10,F,20,30,74,2,1,70,2,1 +0,5,0,72,72,146,10,F,20,30,72,2,1,70,2,1 +0,5,0,72,72,146,10,F,20,30,72,2,1,70,2,1 +0,5,0,71,71,146,10,F,20,30,71,2,1,70,2,1 +0,5,0,74,74,146,10,F,20,30,74,2,1,70,2,1 +0,5,0,70,70,151,10,F,20,30,70,2,1,70,2,1 +0,5,0,69,69,151,10,F,20,30,69,2,1,70,2,1 +0,5,0,70,70,151,10,F,20,30,70,2,1,70,2,1 +0,5,0,70,70,151,10,F,20,30,70,2,1,70,2,1 +0,5,0,74,74,151,10,F,20,30,74,2,1,70,2,1 +0,5,0,74,74,151,10,F,20,30,74,2,1,70,2,1 +0,5,0,73,73,151,10,F,20,30,73,2,1,70,2,1 +0,5,0,78,78,151,10,F,20,30,78,2,1,70,2,1 +0,5,0,78,78,151,10,F,20,30,78,2,1,70,2,1 +0,5,0,83,83,151,10,F,20,30,83,2,1,70,2,1 +0,5,0,84,84,151,10,F,20,30,84,2,1,70,2,1 +0,5,0,90,90,151,10,F,20,30,90,2,1,70,2,1 +0,5,0,96,96,151,10,F,20,30,96,2,1,70,2,1 +0,5,0,98,98,151,10,F,20,30,98,2,1,70,2,1 +0,5,0,106,106,151,10,F,20,30,106,2,1,70,2,1 +0,5,0,110,110,151,10,F,20,30,110,2,1,70,2,1 +0,5,0,117,117,151,10,F,20,30,117,2,1,70,2,1 +0,5,0,124,124,151,10,F,20,30,124,2,1,70,2,1 +0,5,0,130,130,151,10,F,20,30,130,2,1,70,2,1 +0,5,0,136,136,151,10,F,20,30,136,2,1,70,2,1 +0,5,0,141,141,151,10,F,20,30,141,2,1,70,2,1 +0,5,0,146,146,151,10,F,20,30,146,2,1,70,2,1 +0,5,0,149,149,151,10,F,20,30,149,2,1,70,2,1 +0,5,0,150,150,151,10,F,20,30,150,2,1,70,2,1 +0,6,0,152,152,151,10,T,20,30,152,2,1,70,2,1 +0,6,0,151,151,151,10,F,20,30,151,2,1,70,2,1 +0,6,0,149,149,151,10,F,20,30,149,2,1,70,2,1 +0,6,0,145,145,151,10,F,20,30,145,2,1,70,2,1 +0,6,0,142,142,151,10,F,20,30,142,2,1,70,2,1 +0,6,0,136,136,151,10,F,20,30,136,2,1,70,2,1 +0,6,0,129,129,151,10,F,20,30,129,2,1,70,2,1 +0,6,0,123,123,151,10,F,20,30,123,2,1,70,2,1 +0,6,0,118,118,151,10,F,20,30,118,2,1,70,2,1 +0,6,0,110,110,151,10,F,20,30,110,2,1,70,2,1 +0,6,0,105,105,151,10,F,20,30,105,2,1,70,2,1 +0,6,0,98,98,151,10,F,20,30,98,2,1,70,2,1 +0,6,0,93,93,151,10,F,20,30,93,2,1,70,2,1 +0,6,0,90,90,151,10,F,20,30,90,2,1,70,2,1 +0,6,0,85,85,151,10,F,20,30,85,2,1,70,2,1 +0,6,0,82,82,151,10,F,20,30,82,2,1,70,2,1 +0,6,0,79,79,151,10,F,20,30,79,2,1,70,2,1 +0,6,0,78,78,151,10,F,20,30,78,2,1,70,2,1 +0,6,0,76,76,151,10,F,20,30,76,2,1,70,2,1 +0,6,0,73,73,151,10,F,20,30,73,2,1,70,2,1 +0,6,0,75,75,151,10,F,20,30,75,2,1,70,2,1 +0,6,0,72,72,151,10,F,20,30,72,2,1,70,2,1 +0,6,0,73,73,151,10,F,20,30,73,2,1,70,2,1 +0,6,0,72,72,151,10,F,20,30,72,2,1,70,2,1 +0,6,0,71,71,145,10,F,20,30,71,2,1,70,2,1 +0,6,0,71,71,145,10,F,20,30,71,2,1,70,2,1 +0,6,0,70,70,145,10,F,20,30,70,2,1,70,2,1 +0,6,0,73,73,145,10,F,20,30,73,2,1,70,2,1 +0,6,0,72,72,145,10,F,20,30,72,2,1,70,2,1 +0,6,0,76,76,145,10,F,20,30,76,2,1,70,2,1 +0,6,0,76,76,145,10,F,20,30,76,2,1,70,2,1 +0,6,0,76,76,145,10,F,20,30,76,2,1,70,2,1 +0,6,0,79,79,145,10,F,20,30,79,2,1,70,2,1 +0,6,0,81,81,145,10,F,20,30,81,2,1,70,2,1 +0,6,0,86,86,145,10,F,20,30,86,2,1,70,2,1 +0,6,0,87,87,145,10,F,20,30,87,2,1,70,2,1 +0,6,0,94,94,145,10,F,20,30,94,2,1,70,2,1 +0,6,0,97,97,145,10,F,20,30,97,2,1,70,2,1 +0,6,0,103,103,145,10,F,20,30,103,2,1,70,2,1 +0,6,0,108,108,145,10,F,20,30,108,2,1,70,2,1 +0,6,0,115,115,145,10,F,20,30,115,2,1,70,2,1 +0,6,0,120,120,145,10,F,20,30,120,2,1,70,2,1 +0,6,0,126,126,145,10,F,20,30,126,2,1,70,2,1 +0,6,0,131,131,145,10,F,20,30,131,2,1,70,2,1 +0,6,0,136,136,145,10,F,20,30,136,2,1,70,2,1 +0,6,0,140,140,145,10,F,20,30,140,2,1,70,2,1 +0,6,0,143,143,145,10,F,20,30,143,2,1,70,2,1 +0,6,0,145,145,145,10,F,20,30,145,2,1,70,2,1 +0,7,0,146,146,145,10,T,20,30,146,2,1,70,2,1 +0,7,0,145,145,145,10,F,20,30,145,2,1,70,2,1 +0,7,0,143,143,145,10,F,20,30,143,2,1,70,2,1 +0,7,0,141,141,145,10,F,20,30,141,2,1,70,2,1 +0,7,0,136,136,145,10,F,20,30,136,2,1,70,2,1 +0,7,0,131,131,145,10,F,20,30,131,2,1,70,2,1 +0,7,0,125,125,145,10,F,20,30,125,2,1,70,2,1 +0,7,0,119,119,145,10,F,20,30,119,2,1,70,2,1 +0,7,0,115,115,145,10,F,20,30,115,2,1,70,2,1 +0,7,0,109,109,145,10,F,20,30,109,2,1,70,2,1 +0,7,0,102,102,145,10,F,20,30,102,2,1,70,2,1 +0,7,0,97,97,145,10,F,20,30,97,2,1,70,2,1 +0,7,0,91,91,145,10,F,20,30,91,2,1,70,2,1 +0,7,0,87,87,145,10,F,20,30,87,2,1,70,2,1 +0,7,0,84,84,145,10,F,20,30,84,2,1,70,2,1 +0,7,0,80,80,145,10,F,20,30,80,2,1,70,2,1 +0,7,0,80,80,145,10,F,20,30,80,2,1,70,2,1 +0,7,0,75,75,145,10,F,20,30,75,2,1,70,2,1 +0,7,0,77,77,145,10,F,20,30,77,2,1,70,2,1 +0,7,0,76,76,145,10,F,20,30,76,2,1,70,2,1 +0,7,0,72,72,145,10,F,20,30,72,2,1,70,2,1 +0,7,0,74,74,145,10,F,20,30,74,2,1,70,2,1 +0,7,0,69,69,145,10,F,20,30,69,2,1,70,2,1 +0,7,0,72,72,145,10,F,20,30,72,2,1,70,2,1 +0,7,0,70,70,150,10,F,20,30,70,2,1,70,2,1 +0,7,0,70,70,150,10,F,20,30,70,2,1,70,2,1 +0,7,0,74,74,150,10,F,20,30,74,2,1,70,2,1 +0,7,0,73,73,150,10,F,20,30,73,2,1,70,2,1 +0,7,0,70,70,150,10,F,20,30,70,2,1,70,2,1 +0,7,0,73,73,150,10,F,20,30,73,2,1,70,2,1 +0,7,0,73,73,150,10,F,20,30,73,2,1,70,2,1 +0,7,0,75,75,150,10,F,20,30,75,2,1,70,2,1 +0,7,0,81,81,150,10,F,20,30,81,2,1,70,2,1 +0,7,0,82,82,150,10,F,20,30,82,2,1,70,2,1 +0,7,0,83,83,150,10,F,20,30,83,2,1,70,2,1 +0,7,0,91,91,150,10,F,20,30,91,2,1,70,2,1 +0,7,0,95,95,150,10,F,20,30,95,2,1,70,2,1 +0,7,0,97,97,150,10,F,20,30,97,2,1,70,2,1 +0,7,0,104,104,150,10,F,20,30,104,2,1,70,2,1 +0,7,0,112,112,150,10,F,20,30,112,2,1,70,2,1 +0,7,0,116,116,150,10,F,20,30,116,2,1,70,2,1 +0,7,0,122,122,150,10,F,20,30,122,2,1,70,2,1 +0,7,0,130,130,150,10,F,20,30,130,2,1,70,2,1 +0,7,0,135,135,150,10,F,20,30,135,2,1,70,2,1 +0,7,0,141,141,150,10,F,20,30,141,2,1,70,2,1 +0,7,0,144,144,150,10,F,20,30,144,2,1,70,2,1 +0,7,0,147,147,150,10,F,20,30,147,2,1,70,2,1 +0,7,0,149,149,150,10,F,20,30,149,2,1,70,2,1 +0,8,0,150,150,150,10,T,20,30,150,2,1,70,2,1 +0,8,0,150,150,150,10,F,20,30,150,2,1,70,2,1 +0,8,0,148,148,150,10,F,20,30,148,2,1,70,2,1 +0,8,0,145,145,150,10,F,20,30,145,2,1,70,2,1 +0,8,0,140,140,150,10,F,20,30,140,2,1,70,2,1 +0,8,0,135,135,150,10,F,20,30,135,2,1,70,2,1 +0,8,0,129,129,150,10,F,20,30,129,2,1,70,2,1 +0,8,0,122,122,150,10,F,20,30,122,2,1,70,2,1 +0,8,0,116,116,150,10,F,20,30,116,2,1,70,2,1 +0,8,0,110,110,150,10,F,20,30,110,2,1,70,2,1 +0,8,0,104,104,150,10,F,20,30,104,2,1,70,2,1 +0,8,0,101,101,150,10,F,20,30,101,2,1,70,2,1 +0,8,0,95,95,150,10,F,20,30,95,2,1,70,2,1 +0,8,0,90,90,150,10,F,20,30,90,2,1,70,2,1 +0,8,0,87,87,150,10,F,20,30,87,2,1,70,2,1 +0,8,0,83,83,150,10,F,20,30,83,2,1,70,2,1 +0,8,0,82,82,150,10,F,20,30,82,2,1,70,2,1 +0,8,0,79,79,150,10,F,20,30,79,2,1,70,2,1 +0,8,0,72,72,150,10,F,20,30,72,2,1,70,2,1 +0,8,0,71,71,150,10,F,20,30,71,2,1,70,2,1 +0,8,0,72,72,150,10,F,20,30,72,2,1,70,2,1 +0,8,0,70,70,150,10,F,20,30,70,2,1,70,2,1 +0,8,0,74,74,150,10,F,20,30,74,2,1,70,2,1 +0,8,0,69,69,150,10,F,20,30,69,2,1,70,2,1 +0,8,0,69,69,150,10,F,20,30,69,2,1,70,2,1 +0,8,0,69,69,150,10,F,20,30,69,2,1,70,2,1 +0,8,0,72,72,150,10,F,20,30,72,2,1,70,2,1 +0,8,0,73,73,150,10,F,20,30,73,2,1,70,2,1 +0,8,0,72,72,150,10,F,20,30,72,2,1,70,2,1 +0,8,0,74,74,150,10,F,20,30,74,2,1,70,2,1 +0,8,0,76,76,150,10,F,20,30,76,2,1,70,2,1 +0,8,0,78,78,150,10,F,20,30,78,2,1,70,2,1 +0,8,0,77,77,150,10,F,20,30,77,2,1,70,2,1 +0,8,0,82,82,150,10,F,20,30,82,2,1,70,2,1 +0,8,0,88,88,150,10,F,20,30,88,2,1,70,2,1 +0,8,0,88,88,150,10,F,20,30,88,2,1,70,2,1 +0,8,0,93,93,150,10,F,20,30,93,2,1,70,2,1 +0,8,0,98,98,150,10,F,20,30,98,2,1,70,2,1 +0,8,0,105,105,150,10,F,20,30,105,2,1,70,2,1 +0,8,0,109,109,150,10,F,20,30,109,2,1,70,2,1 +0,8,0,116,116,150,10,F,20,30,116,2,1,70,2,1 +0,8,0,123,123,150,10,F,20,30,123,2,1,70,2,1 +0,8,0,129,129,150,10,F,20,30,129,2,1,70,2,1 +0,8,0,135,135,150,10,F,20,30,135,2,1,70,2,1 +0,8,0,140,140,150,10,F,20,30,140,2,1,70,2,1 +0,8,0,145,145,150,10,F,20,30,145,2,1,70,2,1 +0,8,0,148,148,150,10,F,20,30,148,2,1,70,2,1 +0,8,0,150,150,150,10,F,20,30,150,2,1,70,2,1 +0,9,0,151,151,150,10,T,20,30,151,2,1,70,2,1 +0,9,0,150,150,150,10,F,20,30,150,2,1,70,2,1 +0,9,0,148,148,150,10,F,20,30,148,2,1,70,2,1 +0,9,0,144,144,150,10,F,20,30,144,2,1,70,2,1 +0,9,0,140,140,150,10,F,20,30,140,2,1,70,2,1 +0,9,0,136,136,150,10,F,20,30,136,2,1,70,2,1 +0,9,0,130,130,150,10,F,20,30,130,2,1,70,2,1 +0,9,0,123,123,150,10,F,20,30,123,2,1,70,2,1 +0,9,0,118,118,150,10,F,20,30,118,2,1,70,2,1 +0,9,0,110,110,150,10,F,20,30,110,2,1,70,2,1 +0,9,0,105,105,150,10,F,20,30,105,2,1,70,2,1 +0,9,0,98,98,150,10,F,20,30,98,2,1,70,2,1 +0,9,0,96,96,150,10,F,20,30,96,2,1,70,2,1 +0,9,0,91,91,150,10,F,20,30,91,2,1,70,2,1 +0,9,0,86,86,150,10,F,20,30,86,2,1,70,2,1 +0,9,0,80,80,150,10,F,20,30,80,2,1,70,2,1 +0,9,0,79,79,150,10,F,20,30,79,2,1,70,2,1 +0,9,0,79,79,150,10,F,20,30,79,2,1,70,2,1 +0,9,0,73,73,150,10,F,20,30,73,2,1,70,2,1 +0,9,0,75,75,150,10,F,20,30,75,2,1,70,2,1 +0,9,0,74,74,150,10,F,20,30,74,2,1,70,2,1 +0,9,0,75,75,150,10,F,20,30,75,2,1,70,2,1 +0,9,0,71,71,150,10,F,20,30,71,2,1,70,2,1 +0,9,0,69,69,150,10,F,20,30,69,2,1,70,2,1 +0,9,0,70,70,152,10,F,20,30,70,2,1,70,2,1 +0,9,0,69,69,152,10,F,20,30,69,2,1,70,2,1 +0,9,0,70,70,152,10,F,20,30,70,2,1,70,2,1 +0,9,0,74,74,152,10,F,20,30,74,2,1,70,2,1 +0,9,0,74,74,152,10,F,20,30,74,2,1,70,2,1 +0,9,0,74,74,152,10,F,20,30,74,2,1,70,2,1 +0,9,0,75,75,152,10,F,20,30,75,2,1,70,2,1 +0,9,0,78,78,152,10,F,20,30,78,2,1,70,2,1 +0,9,0,79,79,152,10,F,20,30,79,2,1,70,2,1 +0,9,0,80,80,152,10,F,20,30,80,2,1,70,2,1 +0,9,0,83,83,152,10,F,20,30,83,2,1,70,2,1 +0,9,0,91,91,152,10,F,20,30,91,2,1,70,2,1 +0,9,0,94,94,152,10,F,20,30,94,2,1,70,2,1 +0,9,0,98,98,152,10,F,20,30,98,2,1,70,2,1 +0,9,0,104,104,152,10,F,20,30,104,2,1,70,2,1 +0,9,0,112,112,152,10,F,20,30,112,2,1,70,2,1 +0,9,0,119,119,152,10,F,20,30,119,2,1,70,2,1 +0,9,0,124,124,152,10,F,20,30,124,2,1,70,2,1 +0,9,0,131,131,152,10,F,20,30,131,2,1,70,2,1 +0,9,0,136,136,152,10,F,20,30,136,2,1,70,2,1 +0,9,0,143,143,152,10,F,20,30,143,2,1,70,2,1 +0,9,0,147,147,152,10,F,20,30,147,2,1,70,2,1 +0,9,0,150,150,152,10,F,20,30,150,2,1,70,2,1 +0,9,0,151,151,152,10,F,20,30,151,2,1,70,2,1 +0,10,0,153,153,152,10,T,20,30,153,2,1,70,2,1 +0,10,0,152,152,152,10,F,20,30,152,2,1,70,2,1 +0,10,0,150,150,152,10,F,20,30,150,2,1,70,2,1 +0,10,0,147,147,152,10,F,20,30,147,2,1,70,2,1 +0,10,0,142,142,152,10,F,20,30,142,2,1,70,2,1 +0,10,0,137,137,152,10,F,20,30,137,2,1,70,2,1 +0,10,0,131,131,152,10,F,20,30,131,2,1,70,2,1 +0,10,0,124,124,152,10,F,20,30,124,2,1,70,2,1 +0,10,0,118,118,152,10,F,20,30,118,2,1,70,2,1 +0,10,0,112,112,152,10,F,20,30,112,2,1,70,2,1 +0,10,0,104,104,152,10,F,20,30,104,2,1,70,2,1 +0,10,0,98,98,152,10,F,20,30,98,2,1,70,2,1 +0,10,0,95,95,152,10,F,20,30,95,2,1,70,2,1 +0,10,0,91,91,152,10,F,20,30,91,2,1,70,2,1 +0,10,0,87,87,152,10,F,20,30,87,2,1,70,2,1 +0,10,0,80,80,152,10,F,20,30,80,2,1,70,2,1 +0,10,0,78,78,152,10,F,20,30,78,2,1,70,2,1 +0,10,0,75,75,152,10,F,20,30,75,2,1,70,2,1 +0,10,0,73,73,152,10,F,20,30,73,2,1,70,2,1 +0,10,0,75,75,152,10,F,20,30,75,2,1,70,2,1 +0,10,0,74,74,152,10,F,20,30,74,2,1,70,2,1 +0,10,0,71,71,152,10,F,20,30,71,2,1,70,2,1 +0,10,0,71,71,152,10,F,20,30,71,2,1,70,2,1 +0,10,0,72,72,152,10,F,20,30,72,2,1,70,2,1 +0,10,0,72,72,154,10,F,20,30,72,2,1,70,2,1 +0,10,0,73,73,154,10,F,20,30,73,2,1,70,2,1 +0,10,0,69,69,154,10,F,20,30,69,2,1,70,2,1 +0,10,0,69,69,154,10,F,20,30,69,2,1,70,2,1 +0,10,0,75,75,154,10,F,20,30,75,2,1,70,2,1 +0,10,0,76,76,154,10,F,20,30,76,2,1,70,2,1 +0,10,0,75,75,154,10,F,20,30,75,2,1,70,2,1 +0,10,0,75,75,154,10,F,20,30,75,2,1,70,2,1 +0,10,0,80,80,154,10,F,20,30,80,2,1,70,2,1 +0,10,0,84,84,154,10,F,20,30,84,2,1,70,2,1 +0,10,0,86,86,154,10,F,20,30,86,2,1,70,2,1 +0,10,0,88,88,154,10,F,20,30,88,2,1,70,2,1 +0,10,0,95,95,154,10,F,20,30,95,2,1,70,2,1 +0,10,0,99,99,154,10,F,20,30,99,2,1,70,2,1 +0,10,0,106,106,154,10,F,20,30,106,2,1,70,2,1 +0,10,0,113,113,154,10,F,20,30,113,2,1,70,2,1 +0,10,0,120,120,154,10,F,20,30,120,2,1,70,2,1 +0,10,0,126,126,154,10,F,20,30,126,2,1,70,2,1 +0,10,0,132,132,154,10,F,20,30,132,2,1,70,2,1 +0,10,0,139,139,154,10,F,20,30,139,2,1,70,2,1 +0,10,0,145,145,154,10,F,20,30,145,2,1,70,2,1 +0,10,0,149,149,154,10,F,20,30,149,2,1,70,2,1 +0,10,0,151,151,154,10,F,20,30,151,2,1,70,2,1 +0,10,0,154,154,154,10,F,20,30,154,2,1,70,2,1 +0,11,0,154,154,154,10,T,20,30,154,2,1,70,2,1 +0,11,0,154,154,154,10,F,20,30,154,2,1,70,2,1 +0,11,0,152,152,154,10,F,20,30,152,2,1,70,2,1 +0,11,0,148,148,154,10,F,20,30,148,2,1,70,2,1 +0,11,0,143,143,154,10,F,20,30,143,2,1,70,2,1 +0,11,0,138,138,154,10,F,20,30,138,2,1,70,2,1 +0,11,0,133,133,154,10,F,20,30,133,2,1,70,2,1 +0,11,0,127,127,154,10,F,20,30,127,2,1,70,2,1 +0,11,0,120,120,154,10,F,20,30,120,2,1,70,2,1 +0,11,0,111,111,154,10,F,20,30,111,2,1,70,2,1 +0,11,0,105,105,154,10,F,20,30,105,2,1,70,2,1 +0,11,0,99,99,154,10,F,20,30,99,2,1,70,2,1 +0,11,0,94,94,154,10,F,20,30,94,2,1,70,2,1 +0,11,0,88,88,154,10,F,20,30,88,2,1,70,2,1 +0,11,0,85,85,154,10,F,20,30,85,2,1,70,2,1 +0,11,0,82,82,154,10,F,20,30,82,2,1,70,2,1 +0,11,0,80,80,154,10,F,20,30,80,2,1,70,2,1 +0,11,0,75,75,154,10,F,20,30,75,2,1,70,2,1 +0,11,0,73,73,154,10,F,20,30,73,2,1,70,2,1 +0,11,0,73,73,154,10,F,20,30,73,2,1,70,2,1 +0,11,0,74,74,154,10,F,20,30,74,2,1,70,2,1 +0,11,0,73,73,154,10,F,20,30,73,2,1,70,2,1 +0,11,0,72,72,154,10,F,20,30,72,2,1,70,2,1 +0,11,0,71,71,154,10,F,20,30,71,2,1,70,2,1 +0,11,0,68,68,145,10,F,20,30,68,2,1,70,2,1 +0,11,0,71,71,145,10,F,20,30,71,2,1,70,2,1 +0,11,0,70,70,145,10,F,20,30,70,2,1,70,2,1 +0,11,0,72,72,145,10,F,20,30,72,2,1,70,2,1 +0,11,0,73,73,145,10,F,20,30,73,2,1,70,2,1 +0,11,0,75,75,145,10,F,20,30,75,2,1,70,2,1 +0,11,0,75,75,145,10,F,20,30,75,2,1,70,2,1 +0,11,0,78,78,145,10,F,20,30,78,2,1,70,2,1 +0,11,0,79,79,145,10,F,20,30,79,2,1,70,2,1 +0,11,0,79,79,145,10,F,20,30,79,2,1,70,2,1 +0,11,0,86,86,145,10,F,20,30,86,2,1,70,2,1 +0,11,0,87,87,145,10,F,20,30,87,2,1,70,2,1 +0,11,0,93,93,145,10,F,20,30,93,2,1,70,2,1 +0,11,0,96,96,145,10,F,20,30,96,2,1,70,2,1 +0,11,0,104,104,145,10,F,20,30,104,2,1,70,2,1 +0,11,0,107,107,145,10,F,20,30,107,2,1,70,2,1 +0,11,0,113,113,145,10,F,20,30,113,2,1,70,2,1 +0,11,0,120,120,145,10,F,20,30,120,2,1,70,2,1 +0,11,0,126,126,145,10,F,20,30,126,2,1,70,2,1 +0,11,0,132,132,145,10,F,20,30,132,2,1,70,2,1 +0,11,0,136,136,145,10,F,20,30,136,2,1,70,2,1 +0,11,0,140,140,145,10,F,20,30,140,2,1,70,2,1 +0,11,0,143,143,145,10,F,20,30,143,2,1,70,2,1 +0,11,0,144,144,145,10,F,20,30,144,2,1,70,2,1 +0,12,0,145,145,145,10,T,20,30,145,2,1,70,2,1 +0,12,0,145,145,145,10,F,20,30,145,2,1,70,2,1 +0,12,0,143,143,145,10,F,20,30,143,2,1,70,2,1 +0,12,0,139,139,145,10,F,20,30,139,2,1,70,2,1 +0,12,0,136,136,145,10,F,20,30,136,2,1,70,2,1 +0,12,0,131,131,145,10,F,20,30,131,2,1,70,2,1 +0,12,0,126,126,145,10,F,20,30,126,2,1,70,2,1 +0,12,0,119,119,145,10,F,20,30,119,2,1,70,2,1 +0,12,0,115,115,145,10,F,20,30,115,2,1,70,2,1 +0,12,0,109,109,145,10,F,20,30,109,2,1,70,2,1 +0,12,0,101,101,145,10,F,20,30,101,2,1,70,2,1 +0,12,0,96,96,145,10,F,20,30,96,2,1,70,2,1 +0,12,0,94,94,145,10,F,20,30,94,2,1,70,2,1 +0,12,0,88,88,145,10,F,20,30,88,2,1,70,2,1 +0,12,0,85,85,145,10,F,20,30,85,2,1,70,2,1 +0,12,0,83,83,145,10,F,20,30,83,2,1,70,2,1 +0,12,0,77,77,145,10,F,20,30,77,2,1,70,2,1 +0,12,0,77,77,145,10,F,20,30,77,2,1,70,2,1 +0,12,0,77,77,145,10,F,20,30,77,2,1,70,2,1 +0,12,0,74,74,145,10,F,20,30,74,2,1,70,2,1 +0,12,0,73,73,145,10,F,20,30,73,2,1,70,2,1 +0,12,0,70,70,145,10,F,20,30,70,2,1,70,2,1 +0,12,0,73,73,145,10,F,20,30,73,2,1,70,2,1 +0,12,0,72,72,145,10,F,20,30,72,2,1,70,2,1 diff --git a/docker/rotorhazard/race_day.py b/docker/rotorhazard/race_day.py new file mode 100644 index 0000000..1ba329e --- /dev/null +++ b/docker/rotorhazard/race_day.py @@ -0,0 +1,221 @@ +"""Mock **race-day autopilot** — emulate realistic races via the GridFPV test plugin. + +Run this against a RotorHazard that has the ``gridfpv`` + ``gridfpv_mock`` plugins loaded (the +``cargo xtask race-day`` harness does this for you inside the dev container). It connects to RH's +socket.io, then **watches the race state**: whenever *you* stage + start a heat in the GridFPV +Director (driving RH into RACING), the autopilot emulates that heat's race over the wire — +injecting realistic per-pilot laps + RSSI bells through ``gridfpv_mock_pass`` on the nodes the +Director seated (those tuned to a channel). You drive the application; the test plugin drives the +races. + +Pick a scenario (a "race-day personality") as argv[1]. Each shapes pace, spread, and the marshaling +edge cases (missed laps, false passes, a DNF) so you can practice different situations: + + clean smooth, steady-pace laps, strong signal (the happy path) + varied per-pilot pace spread + lap-to-lap jitter (a realistic leaderboard) + messy marshaling practice: a missed lap, a false/extra pass, and a DNF + pack a tight field crossing close together (close finishes) + +It loops indefinitely: every heat you run gets freshly emulated. Ctrl-C (or stopping the harness) +ends it. Re-running a heat re-emulates it. +""" + +import random +import sys +import threading +import time + +import socketio + +RH_URL = "http://localhost:5000" + +# RotorHazard race_status integers (see docker/rotorhazard/README.md): 1 = racing. +RACING = 1 + +# --------------------------------------------------------------------------------------------- +# Scenarios — each returns a per-node schedule of passes given the list of seated node indices. +# A schedule entry is (t_seconds_from_race_start, node_index, peak_rssi). The autopilot sorts the +# merged timeline and emits a gridfpv_mock_pass at each instant. +# --------------------------------------------------------------------------------------------- + +BASELINE = 70 +STRONG = 165 +WEAK = 110 +FALSE_PEAK = 100 # a spurious low bump a marshal should void + + +def _laps(node, count, lap_s, jitter, peak, start=0.6, rng=random): + """A node's clean run: a holeshot then `count` laps at ~`lap_s` pace with ±jitter, all at + `peak`. Returns [(t, node, peak), …] including the lap-0 holeshot.""" + out = [] + t = start + for _ in range(count + 1): # +1 for the holeshot (lap 0) + out.append((t, node, peak + rng.randint(-6, 6))) + t += lap_s * (1.0 + rng.uniform(-jitter, jitter)) + return out + + +def scenario_clean(nodes, rng): + out = [] + for n in nodes: + out += _laps(n, 4, 7.0, 0.06, STRONG, rng=rng) + return out + + +def scenario_varied(nodes, rng): + out = [] + for i, n in enumerate(nodes): + pace = 6.0 + i * 0.9 # each pilot a bit slower than the last — a clear spread + peak = STRONG - i * 8 + out += _laps(n, 5, pace, 0.18, max(peak, WEAK), rng=rng) + return out + + +def scenario_messy(nodes, rng): + """Marshaling practice: one node misses a lap, one gets a false/extra pass, the last DNFs.""" + out = [] + for i, n in enumerate(nodes): + if i == len(nodes) - 1 and len(nodes) > 1: + # DNF: only a holeshot + 2 laps, then nothing. + out += _laps(n, 2, 7.5, 0.1, WEAK, rng=rng) + continue + laps = _laps(n, 4, 7.0, 0.1, STRONG, rng=rng) + if i == 0: + # Missed crossing: drop lap 2 (a gate miss the marshal must add back). + laps = [p for k, p in enumerate(laps) if k != 2] + if i == 1: + # False pass: a low-peak bump ~1.5 s after the holeshot (a bounce/reflection to void). + t0 = laps[0][0] + laps.append((t0 + 1.5, n, FALSE_PEAK)) + out += laps + return out + + +def scenario_pack(nodes, rng): + out = [] + base_pace = 6.5 + for n in nodes: + # Nearly identical schedules so the field crosses close together (dedup/ordering + close + # finishes). Tiny jitter only. + out += _laps(n, 4, base_pace, 0.03, STRONG, start=0.6, rng=rng) + return out + + +SCENARIOS = { + "clean": scenario_clean, + "varied": scenario_varied, + "messy": scenario_messy, + "pack": scenario_pack, +} + + +# --------------------------------------------------------------------------------------------- +# The autopilot +# --------------------------------------------------------------------------------------------- + + +class RaceDay: + def __init__(self, sio, scenario, seed=0): + self.sio = sio + self.scenario = scenario + self.rng = random.Random(seed) + self.worker = None + self.stop = threading.Event() + self.state_nodes = None + self.state_evt = threading.Event() + + def tuned_nodes(self, timeout=3.0): + """Ask the mock plugin for node state; return the indices tuned to a channel (the seats the + Director set up for this heat).""" + self.state_nodes = None + self.state_evt.clear() + self.sio.emit("gridfpv_mock_state") + self.state_evt.wait(timeout) + nodes = self.state_nodes or [] + return [n["index"] for n in nodes if n.get("frequency")] + + def on_state_ack(self, data): + if data.get("action") == "state": + self.state_nodes = data.get("nodes", []) + self.state_evt.set() + + def start_race(self): + self.stop_race() # cancel any prior emulation + self.stop.clear() + self.worker = threading.Thread(target=self._run, daemon=True) + self.worker.start() + + def stop_race(self): + self.stop.set() + w = self.worker + if w and w.is_alive(): + w.join(timeout=1.0) + self.worker = None + + def _run(self): + nodes = self.tuned_nodes() + if not nodes: + print("race-day: no tuned/seated nodes found — did the heat seat any pilots?", flush=True) + return + for n in nodes: + self.sio.emit("gridfpv_mock_reset", {"node": n}) + schedule = sorted(SCENARIOS[self.scenario](nodes, self.rng)) + print( + f"race-day [{self.scenario}]: emulating {len(schedule)} passes across nodes {nodes}", + flush=True, + ) + t_start = time.monotonic() + for t, node, peak in schedule: + # Sleep until this pass's instant, bailing promptly if the race ended. + while True: + if self.stop.is_set(): + print("race-day: race ended — stopping emulation", flush=True) + return + ahead = t - (time.monotonic() - t_start) + if ahead <= 0: + break + time.sleep(min(ahead, 0.1)) + self.sio.emit( + "gridfpv_mock_pass", + {"node": node, "peak": int(peak), "baseline": BASELINE, "width": 14}, + ) + print("race-day: scenario complete — finish the heat in the Director when ready", flush=True) + + +def main(): + scenario = sys.argv[1] if len(sys.argv) > 1 else "clean" + if scenario not in SCENARIOS: + print(f"unknown scenario '{scenario}'. choose: {', '.join(SCENARIOS)}", flush=True) + sys.exit(2) + + sio = socketio.Client(reconnection=True) + autopilot = RaceDay(sio, scenario) + + @sio.on("gridfpv_mock_ack") + def _ack(data): + autopilot.on_state_ack(data) + + @sio.on("race_status") + def _status(data): + if data.get("race_status") == RACING: + autopilot.start_race() + else: + autopilot.stop_race() + + sio.connect(RH_URL, wait_timeout=10) + # No lap-minimum filter, so the brisk emulated laps all record. + sio.emit("set_option", {"option": "MIN_LAP_TIME", "value": "0"}) + print( + f"race-day autopilot ready [{scenario}] — stage + start a heat in the GridFPV Director and " + "watch it race. Ctrl-C to stop.", + flush=True, + ) + try: + sio.wait() + except KeyboardInterrupt: + autopilot.stop_race() + sio.disconnect() + + +if __name__ == "__main__": + main() diff --git a/docs/architecture.html b/docs/architecture.html index 4aeb2af..3e80b66 100644 --- a/docs/architecture.html +++ b/docs/architecture.html @@ -25,7 +25,9 @@

Architecture

This doc decides system shape and stack. It deliberately defers the things that earn their own docs: the adapter interface internals (Timer Adapters), the wire contract - (Protocol), the Cloud warehouse and season model + (Protocol), the live race-state projection and its + server-time clock (Live State & the Race Clock), + the Cloud warehouse and season model (Cloud & Season), the client surfaces (Clients), the broadcast subsystem (Streaming), external integrations @@ -228,18 +230,28 @@

4. Conceptual data model

pipeline), and the classes it runs. Everything below belongs to an event.
Class / category
A field within an event (e.g. Mini, Micro, a sim class) with its own format and - bracket — phases run per class.
+ bracket — phases run per class. Classes are selected from an app-level class + registry (nine locked built-ins with fixed ids for stable cross-event identity, plus Custom); + a class is identified by a ClassId (planned: promoted into the events crate, so + log entries can be tagged by class).
Track
The layout flown — a physical track for IRL, or a Velocidrone spec track (with its track id) for sim.
Pilot & entry
A pilot is a person, ideally a stable identity across events - (the global registry; see §9). An entry is a pilot's participation - in one event's class — check-in / presence, plus the binding from a timer's local - reference (a node seat, a sim player name) to the pilot.
+ (the app-level pilot directory; see §9). An entry is a pilot's membership + in one event's class — and that membership is presence (no separate check-in + flag) — plus the binding from a timer's local reference (a node seat, a sim player name) to the + pilot. IRL membership is RD-added; sim membership is auto from the adapter's + CompetitorSeen.
Heat / round
Scheduling structures the race engine produces — who flies together, when, in - which round. Generated by the format and adjustable before they fly.
+ which round. Generated by the format (or filled manually) and adjustable before they fly. + Rounds are event-level and class-tagged, identified by a RoundId + (planned: promoted into the events crate alongside ClassId). The + HeatScheduled log entry that creates a heat is tagged with its class, round, + and per-pilot channel/frequency assignments (planned, v0.4 Slice 0 — the + log-tagging slice).
Timing & race events (the log)
The append-only entries: raw observations from adapters, race-state events from the engine, and adjudications from marshaling (see §3). The only source of truth.
@@ -269,6 +281,135 @@

Storage tiers

warehouse are conveniences layered around it.

+

The Event as aggregate root

+

+ The conceptual Event from §4 is the aggregate root of + the whole model: the entire event-sourced fact log — heats, registrations, passes, + marshaling adjudications, configuration — belongs to exactly one event. There is + no fact that floats outside an event, and no fact that spans two. An event's log is the + complete, self-contained history of that event and nothing else. +

+
+

One log per event; every fact has exactly one owning event. + The log is the aggregate boundary. Reads are always event-scoped, and each event + keeps its own dense, gap-free offset sequence starting at 0. Cross-event + answers (career, season) are projections that fold over many event logs — never a single + shared log queried by filter.

+
+

+ Practice is a built-in, always-present event — the one event that exists + before you create anything (Pipeline: Practice, + Roadmap v0.4 Slice 1). It is the same aggregate as any other event in every + respect but one: it is non-persistent. Its log lives only for the session + and is discarded on exit — you can always fly without first naming and saving an event, and + that throwaway flying is itself a real (if ephemeral) event log. +

+ +

One logical model, two storage realizations

+

+ The event-as-container model is logical. It has two distinct physical + realizations — the local desktop Director today, and the Postgres Cloud at v0.7 — unified by + a single backend-agnostic abstraction so the rest of the system never has to know which one + is underneath. +

+
+

The EventLog trait + EventRegistry are the + seam. An EventLog is the append/read interface for one event's + ordered fact log; an EventRegistry resolves an EventId to that + event's log. Both are backend-agnostic: reads are always event-scoped and offsets are + per-event-dense regardless of backend. The engine, projection, and protocol layers hold a + registry and a log — never a database handle — so they cannot tell whether an event's log is + a SQLite file or a row-set in Postgres.

+
+ +

Realization 1 — Local (desktop Director, now)

+

+ Each persistent event is its own SQLite database file — the file that "is + the event," portable and replayable in isolation (the per-event tier from §4). The built-in + Practice event is realized as an in-memory log, lost on + exit — same trait, no file. The EventRegistry is the map from + EventId → that event's log: it opens (or creates) the SQLite + file for a persistent event, or hands back the in-memory log for Practice. +

+

+ Alongside the log, each persistent event's SQLite file carries a small sidecar + meta table (the EventMeta; #115) holding the event's + per-event framing and selections — its selected classes, per-class roster + membership, the event-level rounds (class-tagged), and the chosen + track and timer selection — so an event survives a restart with its full setup intact, not just + its fact log. +

+ +

Realization 2 — Cloud (Postgres, v0.7)

+

+ The Cloud aggregates many events into one shared Postgres database. The + container that was a separate file on the desktop becomes an explicit container + row, and per-event log isolation becomes a foreign key rather than a separate file: +

+
+
events table — the container
+
One row per event: event_id primary key (the UUID the offline Director + minted), plus name, owner, created_at, + visibility, and the rest of the event's framing. This is the explicit + realization of the aggregate root that the desktop expresses implicitly as "a file."
+
event_log table — the facts
+
The append-only fact log for all events in one table, carrying + event_id as a foreign key to events. The + composite primary key (event_id, offset) gives each event its own dense + offset sequence — exactly the per-event-dense offsets the trait promises — while a + recorded_at timestamp and a fact column (JSONB) + carry the entry itself.
+
Materialized projection tables
+
Any projection the warehouse materializes (heat results, standings, career, season) also + carries event_id as a foreign key, so derived rows stay scoped to their owning + event and recompute per event.
+
+

A small schema sketch for the two container tables:

+
CREATE TABLE events (
+    event_id    UUID        PRIMARY KEY,   -- minted by the offline Director
+    name        TEXT        NOT NULL,
+    owner       TEXT        NOT NULL,      -- account / chapter that owns it
+    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
+    visibility  TEXT        NOT NULL DEFAULT 'open'
+    -- ... further event framing (class set, enabled phases, ...)
+);
+
+CREATE TABLE event_log (
+    event_id    UUID        NOT NULL REFERENCES events (event_id),
+    "offset"    BIGINT      NOT NULL,      -- dense, gap-free, per-event, from 0
+    recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    fact        JSONB       NOT NULL,      -- the canonical fact, as received
+    PRIMARY KEY (event_id, "offset")       -- each event keeps its own sequence
+);
+

+ The (event_id, offset) key is also what makes the resumable Cloud push an + idempotent upsert — same offset, same bytes, no duplicate + (Cloud & Season §2). The fuller warehouse concerns — + partitioning the log by event, retention, materialization policy — live in that doc; the + schema above is just the container shape the rest of this section depends on. +

+ +

Why the scoping lives in storage, not the fact model

+
+

The abstraction is what lets the Cloud slot in without touching the + core. Because reads are always event-scoped and offsets are per-event-dense + at the trait, the engine, projection, and protocol layers are written against the + EventLog/EventRegistry abstraction and never learn the backend. The + v0.7 Postgres realization is a new trait implementation behind the same + EventRegistry — a WHERE event_id = … row-set instead of a file — + and nothing in the core changes.

+
+

+ This is precisely why the local design uses per-event logs rather + than tagging every fact with an event_id in one big log: the event scoping + lives in the storage layer, not in the fact model. A fact does not carry + "which event am I in" — the log it lives in is the answer. The Postgres realization + reintroduces an event_id column only because a shared database has no + per-file boundary to lean on; the FK reconstructs, inside one database, the same isolation a + separate SQLite file gives for free. Either way the logical model is identical: one event, + one log, its own dense offsets. +

+

5. Components

Timer adapter layer
@@ -317,13 +458,26 @@

6. One protocol, two transports

Cloud (off-LAN) — a client is configured only with a base URL.

-

Contract defined once, in Rust. The wire types live in a - shared Rust crate — today the canonical event model (gridfpv-events), - extended with the protocol's snapshot/read types as they land — and are generated into - TypeScript for the frontend, so the Director, the Cloud, and every client share one - definition that can't drift. (The event-log vocabulary already generates to TS via - ts-rs with a CI drift check; the projection/snapshot types get their own bindings when - the protocol crate arrives.)

+

Contract defined once, in Rust. The wire types live in shared + Rust crates — the canonical event model (gridfpv-events) plus the protocol's + snapshot/read/control types (gridfpv-server) — and are generated into TypeScript + for the frontend, so the Director, the Cloud, and every client share one definition that can't + drift. (The whole contract — event vocabulary, projections, and the snapshot/stream/control + wire types — already generates to TS via ts-rs into repo-root bindings/ with a CI + drift check; this is no longer a forward-looking note.)

+
+
+

+ 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 @@ -392,16 +546,37 @@

9. Security, trust & identity

and access splits into read and control.

-

Control is privileged; reads are open on the LAN. Running - the race — race control, marshaling, configuration — requires the RD's authenticated - role. Watching it (racer PWA, overlays) is open, or lightly tokened, to anyone on the - LAN. Control authority never leaves the Director.

+

Loopback is trusted; remote control is opt-in. Running the + race — race control, marshaling, configuration — is the RD's privileged capability. On the + Director's own machine it needs no auth at all: a loopback request is the + operator at the keyboard by definition, so the RD console gets full control with no login. + Reaching control from anywhere else (the RD's phone over the LAN) is opt-in, off by + default, gated by an RD-set passphrase. Either way control authority stays + Director-only and LAN-only — it never reaches the Cloud.

+
+
+

Reads are a first-class tier; open on the LAN. Watching the + race — the pilot apps, spectator PWA, overlays — is a scoped read capability every such + client uses, open (or lightly join-token-gated) to anyone on the LAN. A pilot app shows + that pilot's own schedule and laps; reads never confer control.

The Cloud authenticates accounts but is never authoritative. Off-LAN viewers reach the Cloud with accounts, but the Cloud only ever serves the read contract and relays — no race is controlled through it.

+
+

+ Cloud TLS is mandatory and non-negotiable. Anything the Cloud serves or + receives over the public internet — account auth, the read snapshot and change stream, the + Director→Cloud log push — travels over HTTPS / WSS end-to-end, with zero + unencrypted traffic over the internet, ever. This is a hard, enforced requirement + of the architecture, not a recommendation. On the trusted LAN plain HTTP is acceptable now; + the v0.5 PWA / remote-control path requires a TLS plan (self-signed or challenge-response) + so the passphrase never crosses untrusted WiFi in clear (mechanics in + Protocol §5). +

+

Identity has two layers. Within an event, a timer's local reference (a node seat, a sim player name) is bound to a pilot at registration. Across @@ -412,9 +587,10 @@

9. Security, trust & identity

- Wire-level mechanics — how the RD role authenticates, token/session format, and how - read access is scoped on LAN vs Cloud — are the Protocol - spec's job. The cross-event registry and the reconcile-on-sync rules belong to + Wire-level mechanics — the loopback source-IP check and remote-control passphrase, the + token/session format, and how read access is scoped on LAN vs Cloud — are the + Protocol spec's job. The cross-event registry and the + reconcile-on-sync rules belong to Cloud & Season. This section fixes only the trust boundaries.

@@ -470,17 +646,23 @@

Open — tracked in their own docs

two adapters (RotorHazard, Velocidrone) translate validated real wire formats; a lap is a re-derivable projection of the pass stream. Remaining detail (richer signal summary, signal-based recovery) — Timer Adapters. -
  • Protocol message shapes, realtime transport & auth. - Snapshot format, change envelope, WS-vs-SSE, and the RD's privileged write path — - Protocol.
  • +
  • Built — Protocol message shapes, realtime transport & auth. + The snapshot format, the externally-tagged change envelope, WS-only transport, and the RD's + privileged control path are all built and pinned by a strict contract suite — the as-built + details and remaining refinements are in Protocol.
  • Cloud warehouse, season model & identity reconciliation. The aggregate store, season scoring, and merging local pilots into the global registry — Cloud & Season.
  • Streaming & broadcast subsystem. OBS control, capture / RTMP handling, and the overlay catalogue — Streaming.
  • -
  • Time & clock authority. The authoritative clock - (monotonic for race timing vs wall-clock for logging) and how adapter timestamps — - sim time vs hardware time — map onto one timeline — Timer Adapters.
  • +
  • Resolved (race clock) — Time & clock authority. The + race clock is server-time authoritative: a heat's Armed → Running + and Running → Unofficial transitions are stamped with the server's + recorded_at (µs) at the single append choke point, and the live state derives + elapsed from those instants — header and HUD share the anchor, with no client wall-clock drift + (Live State & the Race Clock §2). Lap/split + intervals stay on the source's own clock (sim time vs hardware time); how those + timelines map onto one axis is still Timer Adapters.
  • Log & schema versioning. How the event-log format evolves while keeping old events readable for long-term open data — paired with the Protocol contract versioning.
  • diff --git a/docs/assets/style.css b/docs/assets/style.css index 18ddfe1..9b31daf 100644 --- a/docs/assets/style.css +++ b/docs/assets/style.css @@ -261,6 +261,23 @@ pre.mermaid { pre.mermaid:not([data-processed]) { visibility: hidden; } pre.mermaid svg { max-width: 100%; height: auto; } +/* --- Code / schema blocks ----------------------------------------------- */ + +/* A literal code/SQL sketch — distinct from the rendered mermaid diagrams. */ +pre.code { + font-family: var(--font-mono); + font-size: 0.82rem; + line-height: 1.5; + background: var(--surface); + border: 1px solid var(--border); + border-left: 4px solid var(--gfpv-green); + border-radius: var(--radius); + padding: 0.9rem 1.1rem; + margin: 1.25rem 0; + overflow-x: auto; + color: var(--text); +} + /* --- Footer / meta ------------------------------------------------------ */ .doc-footer { diff --git a/docs/channel-model.html b/docs/channel-model.html new file mode 100644 index 0000000..b837422 --- /dev/null +++ b/docs/channel-model.html @@ -0,0 +1,184 @@ + + + + + + GridFPV — Channel Model + + + +
    +
    +

    GridFPV

    +

    Channel Model

    +

    Internal design doc · channels ≠ nodes, defined on the timer, dynamically tuned

    +
    + +

    + How GridFPV models video channels and timer seats — the one distinction the rest of the + race-running redesign leans on: a channel is not a node. The timer's + node count caps how many pilots can fly a heat; its available channels are the pool the + engine assigns from, and that pool may be larger than the node count. +

    + +
    +

    + This doc owns the channel / node model and how channels are assigned. + It is the deep dive behind the channel notes in Setup + §Channels, Mains §Channel assignment, and + Timer Adapters §5. When this doc and + Architecture disagree on shape, follow Architecture; + when either disagrees with the Vision, the Vision wins. + This is the as-built (v0.4) Timer-registry model (#73/#117). +

    +
    + +

    1. The principle — channels belong to the timer, not the event

    +
    +

    Channels are a property of the selected timer. GridFPV does not + declare a free-floating “frequency pool” on the event. Each timer in the app-level + Timer registry carries its own channel configuration — a built-in standard FPV catalog, the + adapter's fixed or flexible capability, the timer's available + channels, and its node count. An event simply selects a + timer; the channels come with it.

    +
    +
    +

    The engine allocates; the adapter tunes. The race engine decides + who flies on which channel (it knows the field and avoids conflicts). The adapter + advertises the hardware's capability and node count and, at race time, + dynamically tunes the device's nodes to the assignment. Neither owns the other's + job (Race Engine §5, Timer Adapters §5).

    +
    + +

    2. Channels ≠ nodes

    +

    + The two numbers a timer carries are independent, and conflating them is the bug this model + exists to prevent: +

    +
    +
    node_count — caps pilots-per-heat
    +
    The hardware seat count: the most pilots you can put in one heat on this timer. Defaults to + 8 (DEFAULT_NODE_COUNT, the ubiquitous Raceband R1–R8 width). A heat + with more competitors than the node count is rejected (TooManyForNodes).
    +
    available_channels — the assignable pool
    +
    The raw-MHz channels the RD has made available on this timer, in preference order. + This pool can exceed node_count: a 4-node timer may legitimately + offer 8 channels, so the RD can choose which four of those eight a given heat uses. The Mock timer + seeds this with the 8-entry Raceband grid out of the box.
    +
    +
    +

    + Why they differ: nodes are physical receivers (how many can be timed at once); + channels are tunable frequencies (what each receiver can be set to). A timer can know about more + frequencies than it has receivers. Assignment first-fits from the pool, using at most + node_count of its entries per heat. +

    +
    + +

    3. The standard FPV catalog

    +

    + A built-in catalog of the common analog bands ships with the app (served from + GET /channels). Each entry is { band, channel, mhz }, where + mhz (a u16 centre frequency) is the authoritative value; band + and channel are the friendly label (e.g. Raceband R1, Fatshark F4). + A raw MHz with no catalog match renders as a bare “5800 MHz”. +

    + + + + + + + + + + + +
    BandChannelsNotes
    RacebandR1–R8 (5658…5917)The default racing grid
    FatsharkF1–F8 (5740…5880)
    Boscam A / B / EA1–A8 / B1–B8 / E1–E8Legacy analog bands
    DJIR1–R4 (5660…5770)Clean digital channels
    HDZeroR1–R8Digital, on the Raceband grid
    +

    + Beyond the catalog, a flexible timer accepts arbitrary custom raw-MHz entries + (the UI guards them to a sane VTX range, roughly 5300–6000 MHz). This is how a RotorHazard + timer can be set to frequencies the catalog never named. +

    + +

    4. Per-timer capability — Fixed vs Flexible

    +

    + Each timer adapter declares how it manages channels, beyond the yes/no + “frequency / channel mgmt” capability: +

    +
    +
    Fixed
    +
    The timer supports only an explicit built-in allowed set of catalog + frequencies (Fixed { channels: Vec<u16> }) — e.g. a hardware timer locked to + certain channels. The picker offers only those; no custom raw-MHz.
    +
    Flexible (default)
    +
    The timer accepts any frequency — the standard catalog plus custom raw-MHz, + as RotorHazard allows. The permissive default, and what Mock uses.
    +
    +

    + A sim timer (Velocidrone) declares no channel capability at all: its + available-channels pool is empty and the channel step disappears from the UI. +

    + +

    5. Assignment — static vs per-heat

    +

    + A round declares a channel mode that decides how its heats get their channels: +

    +
    +
    Static — qualifying / GQ-style
    +
    Each member has a fixed per-membership channel (a MemberSlot's + channel, validated against the primary timer's available_channels). Heats + are channel-balanced: one pilot per distinct channel per heat, capped at + node_count. The default for timed_qual (and round_robin, + since carved to tournaments-snapshot, PR #327).
    +
    Per-heat — brackets (default)
    +
    Channels are assigned per heat from the timer's pool by + first-fit in seed order (assign_frequencies), and the RD may + override. The default for head_to_head (and the elimination / multi-main / ZippyQ + bracket formats — carved to tournaments-snapshot / shelved).
    +
    +

    + Either way, open practice is the special case: its “lineup” is + the active channels (the chosen node seats, as node-i refs), so its heats carry no + separate channel assignment — the channels are the seats (see + Practice). +

    + +

    6. Tuning at race time

    +

    + When a heat is armed, the engine's per-node (node_index, frequency_mhz) assignment + is handed to the adapter, which applies it to the hardware — for RotorHazard, emitting a + set_frequency per node before the race stages. The engine decides; the adapter tunes. + Sim sources skip this entirely (no channels to set). +

    +
    +

    IRLThe primary timer's node seats are tuned to the assigned channels + before each heat; conflicts are avoided within the heat by construction.

    +

    SimNo channels, no tuning — the active-channels picker is empty and + the channel step is hidden.

    +
    + +

    7. Open decisions

    +
      +
    1. Resolved — channels live on the timer, not the event. The + Timer registry (#73/#117) holds each timer's catalog capability, available channels, and node + count; an event selects a timer and inherits them.
    2. +
    3. Resolved — node_count caps the heat; the pool may exceed + it. Pilots-per-heat is capped by the node count; available_channels is an + independent, possibly larger pool the assignment first-fits from.
    4. +
    5. Resolved — static vs per-heat assignment. Static (fixed + per-pilot channels, channel-balanced heats) for qualifying / GQ; per-heat first-fit for + brackets; open practice seats channels directly.
    6. +
    7. Custom-MHz validation range. The exact accepted custom raw-MHz bounds + and whether they should be per-adapter rather than a single UI guard.
    8. +
    + + +
    + + diff --git a/docs/clients.html b/docs/clients.html index 2b2c9b0..24b25bb 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 (now shipping in a native Tauri desktop app as the release form; PRs #195, + #204), 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 @@

    1. Three surfaces, one client

    ui["Component library<br/>leaderboard · bracket · clock"] end - rd["RD console<br/>control · dense · authenticated<br/>in Tauri window"] + rd["RD console<br/>control · dense · loopback-trusted<br/>native Tauri app (release) · web app (dev)"] pwa["Racer / spectator PWA<br/>read · installable · push"] ovl["OBS overlays<br/>read-only · lean · browser source"] @@ -83,22 +84,131 @@

    1. Three surfaces, one client

    contract can't drift between surfaces because there is only one client.

    -

    The RD console — control-oriented, dense, authenticated

    +

    The RD console — control-oriented, dense, loopback-trusted

    - 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 — - 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 race director's cockpit, loaded inside the Tauri window + (Architecture §2). The Tauri shell has landed (PRs #195, #204) as the release form — a + self-contained, portable single-file binary with the Director embedded in-process (see the + native-desktop section below); the hosted web app served by the Director remains the + development path. It is the only surface that exercises the protocol's privileged + write/control path — + arming heats, marshaling, configuring the pipeline. Because it runs on the Director's own + machine, it is trusted by loopback and needs no login at all: + the RD opens it and is in (Architecture §9; mechanics in Protocol §5). + 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. + As built (v0.4) — the console information architecture: a home hub of pages, then an + event workspace. The console opens on a home hub whose top-level + navigation is four pages (not modals): Pilots, Classes, + Events, and Timers. The first three of those — pilots, + classes, and timers — are app-level registries you configure once and + select from per event (the “configure once, select per event” model; + Setup). Navigation is hash-based, so a + browser refresh preserves the current page and the hub stays the hub. The header carries the + GridFPV monogram logo and the colored wordmark. The event workspace — the + live race-running surface — is entered from the Events page by opening one + event. +

    +
    +
    +

    + Planned — the event workspace is a sequence of editable stage-pages, not a + “Setup” tab. Inside an event the workspace is a left-to-right sequence of + stage-pages you can revisit and edit at any time: + Classes · Roster · Rounds+Heats · Live control · + Marshaling · Results (Rounds and Heats are combined on one page). There + is no separate “Setup” phase or tab — setting an event up is just visiting its + early stage-pages. A guided setup wizard is a thin first-pass over + those same pages for the common path, and is built last (v0.4 Slice 7) so + the pages exist before the wizard that walks them. One event flies one track. + (The race-running redesign; Setup, + Mains, Roadmap v0.4.) +

    +
    +
    +

    + No local auth UI. Loopback-trust means there is nothing to log into on the + RD's own machine — no login screen before the event picker, no token to paste. The earlier + "RD token" is not a local login; it survives only as the remote-access passphrase + the RD opts into to reach control from off-machine (the mobile-control surface below). A + local RD never types a credential to run a race. +

    +
    +
    +

    + Built for the field — readable on a sunlit laptop. The RD console is + operated outdoors in direct sun, on a medium-size laptop, glanced at mid-race from a few + feet away — so it is designed for field readability: a larger base + text size than a typical desktop app, a dark-mode default (to cut + glare), and high contrast. “Is this legible on a laptop in bright sunlight?” + gates any console UI — favor bigger, high-contrast type over dense/small. (Bumping the + design-system base sizes is tracked as a follow-up.) +

    +
    +
    +

    + The console is "just a co-located web client" (Architecture §2): in the native app it loads + 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 buys a friendly native window and + a self-contained single-file binary — it does not buy a private back + channel. Control authority is a protocol role, not a shell privilege. The native shell + has now landed (PRs #195, #204) as the release form — see the native-desktop section below; + the hosted web app remains the development path. +

    +
    + +

    The native desktop app — the release form (as built; PRs #195, #204)

    +

    + The console's release form is a Tauri 2 desktop app + (src-tauri/) that embeds the Director in-process. On launch it + runs the same gridfpv_app::director::run_director the hosted + gridfpv binary runs, on a tokio task bound to a loopback ephemeral + port (127.0.0.1:0), then opens a native window pointed at + http://127.0.0.1:<port>/. The window loads the Director's own HTTP origin + directly, so the SPA's same-origin API (/snapshot/…, /control/…) and + the realtime WebSocket work with no cross-origin shims. Because it is + loopback, the control path is open with no auth — there is no + passphrase prompt (per the auth model; §1, Protocol §5). The shell + reuses the exact same core rather than duplicating it: one run_director serves both + the hosted binary and the desktop app, so the two cannot drift. (The decision and its + rationale are recorded as D9 in Decisions.) +

    +
    +

    + Self-contained & portable-only. The frontend dist is baked into the + binary via the embed-assets cargo feature, so the produced + gridfpv-desktop / .exe is a single self-contained executable that + serves the console from memory — no external assets folder. Distribution is + portable-only: the gated release workflow builds with + --no-bundle and publishes only that binary. The msi/nsis/appimage/deb + installers were dropped — there is nothing to install, which removes setup + friction for a non-technical RD and makes the app trivially movable. +

    +
    +
    +

    + Data lives next to the executable. Created events persist as SQLite files in + a gridfpv-data/ folder beside the running binary — copy the exe + and that folder together (e.g. to a USB stick) and the events travel with them, true-portable. + If the executable's directory isn't writable (a read-only mount, or + current_exe() can't be resolved), the app falls back to the OS + per-user app-data directory and logs which location is in use. (The built-in + Practice event stays in-memory, matching the hosted Director.) +

    +
    +
    +

    + The hosted Director stays the development path. The native app is + purely additive — it is the release form, not a replacement for the dev loop. + Day-to-day work is unchanged: cargo build -p gridfpv-app --features live serves + the API + RD console at :8080. The desktop crate is excluded + from the root Cargo workspace, so cargo xtask ci never compiles it (CI runners + have no webkit2gtk/GTK); the Tauri build is a separate, deliberate command. Windows packaging + is deferred — the crate is Windows-ready in principle, but no Windows artifact is built and no + cross-compile is attempted.

    @@ -141,24 +251,27 @@

    OBS overlays — read-only, lean, browser sources

    -

    RD mobile-control surface — privileged, mobile, LAN-only (future)

    +

    RD mobile-control surface — privileged, mobile, LAN-only (opt-in, v0.5)

    - Future surface. A second control client for the race director, - mobile-optimized and exposed only on the LAN — never the Cloud. The use - case: the RD is racing themselves, straps into their seat, and needs to start, abort, or - advance the race from their phone without returning to the console. It is the same - privileged write/control path as the console (mechanics in Protocol - §5), just reached from a phone on the LAN. + A second control client for the race director, mobile-optimized and exposed + only on the LAN — never the Cloud. The use case: the RD is racing + themselves, straps into their seat, and needs to start, abort, or advance the race from + their phone without returning to the console. It is the same privileged write/control path + as the console (mechanics in Protocol §5), just reached from a + phone on the LAN. Remote control is opt-in and off by default; the RD turns + it on deliberately for a given event.

    - Stricter authentication than the console. The co-located console gets - its trust from living in the Tauri window on the Director's own machine; a phone - on the LAN has no such standing and must prove itself. This surface - therefore requires explicit RD login or device pairing — stricter than the console's - ambient trust — before it can touch the control path. Control authority stays + Passphrase-gated, because it is not loopback. The co-located console gets + its trust from living on the Director's own machine (loopback, Architecture §9); a + phone on the LAN has no such standing and must present a credential. So + remote control is gated by an RD-set passphrase — the passphrase is the + opt-in: enabling remote control is the act of setting it. Control authority stays Director-only and LAN-only: this surface never widens who can control a race or - over what transport, it only adds a mobile place to exercise the existing Director role. + over what transport, it only adds a mobile place to exercise the existing Director role. The + passphrase must not cross untrusted WiFi in clear, so it rides the v0.5 TLS plan + (Protocol §5; §4 below).

    @@ -167,10 +280,10 @@

    RD mobile-control surface — privileged, mobile, LAN-only (future) SurfaceDirectionAuthTransportShellEmphasis - RD consoleread + controlRD roleLAN (local)Tauridense, keyboard, working tool - Racer / spectator PWAreadopen / light token (LAN) · account (Cloud)LAN or Cloudbrowser → future Capacitorapproachable, mobile-first, installable - OBS overlayread-onlyopen / light tokenLAN or CloudOBS browser sourcetransparent, fixed canvas, lean - RD mobile-control (future)controlRD role, stricter (explicit login / pairing)LAN-onlymobile browser / PWAfocused live-control + RD consoleread + controlloopback-trusted, no loginLAN (local)native Tauri app (release) · web app (dev)dense, keyboard, working tool + Racer / spectator PWAreadopen / join token (LAN) · account (Cloud)LAN or Cloudbrowser → future Capacitorapproachable, mobile-first, installable + OBS overlayread-onlyopen / join tokenLAN or CloudOBS browser sourcetransparent, fixed canvas, lean + RD mobile-control (opt-in, v0.5)controlRD passphrase (opt-in, off by default)LAN-onlymobile browser / PWAfocused live-control @@ -208,6 +321,8 @@

    What's actually being weighed

    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 +382,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

    @@ -357,11 +476,13 @@

    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. + Native apps follow a deliberate symmetry with the Director (Architecture §7). The Director's + RD console is a web UI wrapped in Tauri (now shipped as the release form — a + self-contained portable binary with the Director embedded; PRs #195, #204, see §1); 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 @@ -396,19 +517,24 @@

    7. Open decisions

    size on a transparent background, and an overlay is told which race/class it shows via its URL — coordinated with Streaming, which owns the catalogue and theming. -
  • LAN read access posture. Resolved: open, with an - optional signed join-token. LAN reads are open by default, with an optional signed - join-token (per Protocol) when an event wants to gate access — - pairs with the auth model in Protocol and the trust boundaries - in Architecture §9.
  • +
  • Read access tier. Resolved: a first-class, scoped read + tier — open on the LAN, optional signed join-token. Read is the tier every pilot and + spectator client uses (a pilot app shows that pilot's own scope), not an afterthought of + control. LAN reads are open by default, with an optional signed join-token (per + Protocol §5) when an event wants to gate access, and the same tier + backs the Cloud account's read authorization — pairs with the auth model in + Protocol §5 and the trust boundaries in + Architecture §9.
  • Native push details (deferred). When the Capacitor wrap lands, how native push registration maps onto the Cloud push infrastructure — paired with Cloud & Season.
  • -
  • RD mobile-control surface (future). A privileged, - mobile-optimized control client exposed LAN-only (§1), reusing the control path - (Protocol §5) but requiring stricter authentication than the - console (explicit RD login / device pairing). The auth/pairing mechanics and exact mobile - control UX are future work; control authority stays Director-only and LAN-only.
  • +
  • RD mobile-control surface. Resolved: opt-in, off by + default, gated by an RD-set passphrase (v0.5). A privileged, mobile-optimized control + client exposed LAN-only (§1), reusing the control path (Protocol §5). + Because it is not loopback, it must present a credential — an RD-set passphrase + the RD opts into (the console itself, on loopback, needs none). It rides the v0.5 TLS plan so the + passphrase never crosses untrusted WiFi in clear. The exact mobile control UX is later work; + control authority stays Director-only and LAN-only.
  • diff --git a/docs/cloud.html b/docs/cloud.html index d199a3e..9da628c 100644 --- a/docs/cloud.html +++ b/docs/cloud.html @@ -78,6 +78,13 @@

    1. What the Cloud is — and is not

    over any event's truth (see §5, §6). A Director remains authoritative for its own events and can compute a fully-local season over them with no internet.

    +

    + One Director ↔ one Cloud is an assumption, not a decision. Everything + below describes a Director relating to a single Cloud instance. Whether an RD can + federate one Director to several Cloud instances at once — given the Cloud is + FOSS and self-hostable — is an open question (§8): unresolved, and + flagged here so the single-Cloud framing throughout this doc is read as provisional. +

     flowchart LR
    @@ -200,7 +207,11 @@ 

    3. The warehouse

    Where the Director keeps one SQLite file per event (the file is the event), the Cloud aggregates many events' logs — across classes, chapters, and Directors — into one - Postgres store. It is the third storage tier from Architecture §4. + Postgres store. It is the third storage tier from Architecture §4. The container shape this + builds on — an events table plus an event_log table keyed + (event_id, offset) with the fact as JSONB — is the Cloud storage realization + sketched in Architecture §4; this section adds the + warehouse-scale concerns (aggregation, partitioning, retention) on top of it.

    Raw log vs projections

    @@ -369,10 +380,25 @@

    7. Account model for off-LAN viewers

    Accounts gate reads; control never leaves the Director. A Cloud - account identifies an off-LAN viewer and maps to the read authorization the - Protocol spec defines. The privileged control role - (race control, marshaling, configuration) exists only on the Director and is never granted - by a Cloud account.

    + account identifies an off-LAN viewer and maps to the protocol's first-class scoped + read tier — the same tier the LAN's open / join-token reads use + (Protocol §5). The privileged control role (race control, + marshaling, configuration) exists only on the Director and is never granted by a Cloud + account.

    +
    +
    +

    + Mandatory TLS — zero unencrypted traffic over the internet, ever. + Everything the Cloud serves or receives over the public internet — account auth, the read + snapshot and change stream, and the Director→Cloud log push (§2) — travels over + HTTPS / WSS end-to-end, always. There is no plain-HTTP Cloud mode and no + exception for convenience on a public address. This is a hard, non-negotiable + requirement enforced in the architecture, not a recommendation + (Architecture §9; mechanics in + Protocol §5). It contrasts with the trusted LAN, where plain + HTTP is acceptable because the network is trusted and loopback control carries no + credential at all. +

    Accounts serve a few concrete needs beyond plain viewing:

      @@ -448,6 +474,19 @@

      8. Open decisions

      how the account maps onto the protocol's read-authorization scopes, and which chapter data is private / pre-publication vs the open-data default (shared with the Protocol spec). +
    • Open — Multi-cloud / federation (no decision yet). §1–§7 + assume a Director links to one Cloud. But the Cloud is FOSS and self-hostable, + so an RD may legitimately want to federate a single Director to several Cloud instances + at once — the project SaaS, a league/community-run instance, and a chapter's private + self-host — pushing and reading selectively per instance. This fits the project's FOSS + ethos and is unresolved & deliberately deferred. It ripples through several + decisions above: the per-Director push credential (§2) becomes per-(Director, Cloud) + with per-target selection, batching, and dedup; identity reconciliation (§6) + and the global pilot registry must reconcile across independent clouds with no + single authority; season aggregation (§5) and the + account model (§7) may differ per instance, and a pilot/viewer may hold + accounts on several. Captured now so the single-Cloud assumptions baked into §1–§7 + are known to be provisional; path forward to be designed in a future session.
    • diff --git a/docs/code-conventions.html b/docs/code-conventions.html index cf7e86e..6dd544d 100644 --- a/docs/code-conventions.html +++ b/docs/code-conventions.html @@ -26,7 +26,9 @@

      1. Workspace & crates

      The spine lives under crates/: events (the canonical event model), storage (the append-only log + SQLite), projection, engine (the race engine — heat loop, scoring, marshaling, format generators, - scheduling), adapters (timer adapters), and app (which builds the + scheduling), adapters (timer adapters), server (the protocol + server — snapshot/stream/control wire types + the axum read/realtime/control surface), + and app (which builds the single gridfpv binary). xtask/ and testkit/ (the shared mock-RH test harness) are non-published (publish = false) helper crates. Internal dependencies are path deps declared once in [workspace.dependencies] and inherited with @@ -52,11 +54,20 @@

      2. Checks — one source of truth

      3. Generated wire types

      Rust→TypeScript via ts-rs, drift-gated. Wire types are - generated from Rust with ts-rs, derived - only on the canonical event model in gridfpv-events, exported to repo-root + generated from Rust with ts-rs across the + crates that own contract types — gridfpv-events (the canonical event model), + gridfpv-projection, gridfpv-engine, and gridfpv-server + (the protocol contract) — exported to repo-root bindings/. cargo xtask gen regenerates; cargo xtask ci fails if the checked-in bindings/*.ts drift from the Rust types. Commit - regenerated bindings with the Rust change.

      + regenerated bindings with the Rust change. The protocol contract version + (CONTRACT_VERSION, a monotonic u32) bumps only on a breaking wire + change; additive changes an older client can ignore do not bump it.

      +

      Transferred integers render as a TS number: microsecond times / durations + (i64) and cursors / sequences (u64) are all bounded ≪ 253, + so number is exact and matches serde's JSON-number output — avoiding a + bigint/number mismatch between the two ends. Reserve + string for any full-range u64.

      4. Safety & the data model

      @@ -68,8 +79,8 @@

      4. Safety & the data model

      Integer-microsecond source time. Time is carried as SourceTimeinteger 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 diff --git a/docs/decisions.html b/docs/decisions.html new file mode 100644 index 0000000..4055462 --- /dev/null +++ b/docs/decisions.html @@ -0,0 +1,797 @@ + + + + + + GridFPV — Decisions + + + +

      +
      +

      GridFPV

      +

      Decisions

      +

      Internal design doc · an ADR-style log of the race-engine & heat-lifecycle decisions

      +
      + +

      + A decision log for the race engine and the heat lifecycle, written ADR-style: + context → decision → rationale. These are the calls that shaped the FSM + and the round/format model as they stand in the code today. The mechanics they describe + live in Heat Lifecycle and + Race Engine; this doc records why. +

      + +
      +

      + Each entry is a small accepted decision, not a spec. Where a decision changed an earlier + stance (e.g. an abort once landed at Staged), the entry says so. When this + doc and the Vision disagree, the Vision wins. +

      +
      + +

      D1 · A six-phase heat lifecycle, named for honesty

      +
      +
      Context
      +
      The original heat loop was Scheduled → Staged → Armed → Running → Finished → + Scored, with "Scored" as the terminal. But a scored heat is not yet + official: the grace window for late crossings is still open, marshaling may still + land, and the RD has not committed the result.
      +
      Decision
      +
      The phases are Scheduled → Staged → Armed → Running → Unofficial → Final. + The closed-but-uncommitted state is named Unofficial (not "Scored" or + "Completed"); Final is the locked terminal. The transition that closes the + race is Finished; it lands the heat in Unofficial.
      +
      Rationale
      +
      "Unofficial" tells the truth on the console: a real, displayable result that is + explicitly provisional and can still change. "Final" makes the commit a distinct, deliberate + act (Finalize), and gives Revert a clean inverse. Naming the + transition (Finished) differently from the state + (Unofficial) keeps "the race finished" and "the result is unofficial" as two + separate, accurate statements.
      +
      + +

      D2 · Abort and Restart both reset to Scheduled

      +
      +
      Context
      +
      An earlier model had Abort land in different places depending on where it + was issued (e.g. Armed → Staged), and Restart as a separate + half-reset. That left the off-ramp ambiguous — "abort to where?" depended on timing.
      +
      Decision
      +
      Both Abort and Restart reset the heat all the way to + Scheduled, from any legal state, and the RD re-Stages. Abort + is legal from Staged/Armed/Running; + Restart from Armed/Running/Unofficial.
      +
      Rationale
      +
      One unambiguous landing state for every off-ramp: whatever went wrong, the heat returns + to a clean, un-run state. It collapses a fiddly multi-target table into a uniform rule, + makes the fold trivial (every reset lands at Scheduled), and matches how an RD + actually recovers — stop, reset, re-Stage. Discard (re-run a finalized heat) + follows the same landing; Revert is the one non-resetting off-ramp because its + whole purpose is to keep the result and re-open it for correction.
      +
      + +

      D3 · The two middle forward steps are runtime-driven, not RD commands

      +
      +
      Context
      +
      Armed → Running and Running → Unofficial used to be manual + commands (Start/Finish). But an RD pressing "go" at exactly the + right instant, and "stop" at exactly the win condition, is busywork the system can do more + precisely — and the FSM must stay pure for replay.
      +
      Decision
      +
      The Director's runtime clock appends both transitions: it auto-advances + Armed → Running when the start procedure's hold elapses, and + Running → Unofficial when the win condition plus grace window are met. The old + Arm command is renamed Start (the RD presses + "Start", which arms the heat and runs the start procedure). Two manual overrides remain for + when the clock can't be trusted: SkipCountdown and ForceEnd, each + legal only in its matching state and recording exactly the transition the auto-path would.
      +
      Rationale
      +
      The clock is more precise and frees the RD's hands during the most time-critical moments. + Keeping the FSM module pure (no clock, no RNG) preserves deterministic replay; the runtime — + which does read wall-time — lives outside the pure engine and only ever appends facts + the fold can replay. The overrides are the race-day escape hatch, so a stuck countdown or a + race that must be called now never traps the RD.
      +
      + +

      D4 · The console owns the start tone; the hold is randomized and logged

      +
      +
      Context
      +
      The canonical FPV start is "arm… and… go" after an unpredictable hold, so pilots + can't time the launch. Something must roll that hold and play the tone — and whatever rolls it + must not break replay determinism.
      +
      Decision
      +
      The start procedure (per round, default a randomized 2000–5000ms hold) + is run by the runtime when a heat enters Armed. The runtime picks the delay + once, writes it to the log as HeatStarting { delay_ms }, and + schedules the Running transition for then. The GridFPV console owns the + start tone (cued from HeatStarting), not the timer.
      +
      Rationale
      +
      Logging the chosen delay once makes the random hold a recorded fact: a replay + reads delay_ms and reproduces the exact start, so nothing re-rolls. Letting the + console own the tone keeps the audible start consistent across every timer (sim or hardware), + driven by one source of truth on the wire. The procedure is an extensible enum, so a future + fixed countdown or external arm-trigger is an additive variant.
      +
      + +

      D5 · Open-practice laps are ephemeral; the session is logged

      +
      +
      Context
      +
      Open practice is casual flying over the active channels — pilots come and go and nobody + is scoring. Recording every practice pass would bloat the durable log with data nobody + adjudicates, yet the session boundaries are worth keeping.
      +
      Decision
      +
      An open-practice round is one open heat over the active channels + (node-{i} seats, seeded by AllChannels { channels }), and is + auto-created. The session is logged (the heat's + HeatScheduled and its start/stop transitions), but the per-channel + laps are in-memory only, never appended. A live accumulator holds them and + the served live state takes its phase/clock from the real log, splicing the non-logged laps + over it. There is no win condition (an inert one is stored, never consulted); an optional + time_limit_secs auto-ends the heat (Running → Unofficial), else the + RD ForceEnds.
      +
      Rationale
      +
      The durable log stays lean — it carries the session boundaries and zero practice passes — + while pilots still see live laps. Keying laps by channel (not pilot) fits practice, where seats + are unbound. Driving phase/clock from the real log (not a synthetic one) avoids the drift bug an + earlier fully-synthetic live state had — a Restart or a time-limit end is always + reflected because the phase is the log's.
      +
      + +

      D6 · The win condition is the qualifying metric (unified) — implemented

      +
      +
      Context
      +
      A round used to risk carrying two overlapping scoring knobs: a per-heat "win condition" and + a separate "qualifying metric" for the cross-flight ranking. Two knobs that mean almost the + same thing invite contradiction (rank on best lap, but win on most laps?).
      +
      Decision
      +
      A round carries one WinCondition that drives both per-heat results and the + qualifying ranking. The catalogue is Timed (displayed "Timed — Most + Laps"), FirstToLaps, BestLap, and + BestConsecutive. The qualifying generator ranks each pilot on their best flight + under that same condition (BestLap → fastest lap, BestConsecutive → + fastest N-lap window, Timed → most laps). Open practice does no scoring and stores + an inert condition.
      +
      Rationale
      +
      One rule per round can't contradict itself, and the same scorer serves heat results, + live/provisional ranking, and qualifying aggregation — written once, replayed identically.
      +
      Realized (as built)
      +
      Implemented (PR #188 on devel). The separate metric param was + removed from the timed_qual and round_robin format + schemas (engine/src/format.rs); the ranking metric is now derived from the + round's win condition by qual_metric_for + (server/src/round_engine.rs). The mapping — + timed_qual: Best lap → best-lap, + Best N consecutive → best-consecutive, + Timed (Most Laps) → most-laps, First-to-N → the + best-lap default; round_robin: + Timed → total-laps, else → points. + The rounds param is now labeled "Heats per pilot" (default 3) in + the schema, and EventRounds.svelte shows only the qualifying-applicable win + conditions (First-to-N hidden) with no separate metric field.
      +
      Update (2026-06-30)
      +
      The round_robin half of this mapping was carved out with the + tournament structures (PR #327, preserved on tournaments-snapshot). Release + hardening also landed on the remaining path: timed_qual now chunks large + fields into heats of heat_size (heat ids tq-r{n}-h{m}) and + excludes DQ'd placements from the ranking (#331); and adjudications (DQ / + TimeAdded / lap edits / throw-out / void) now reach round ranking, round standings, class + standings, and seeding via the shared score_heat_window scorer (#329, closing + #226).
      +
      + +

      D7 · The grace window defaults to a bounded 30 seconds

      +
      +
      Context
      +
      When the win condition is met, a trailing pilot may be mid-lap; their final lap should + still count. But the runtime's completion clock must eventually fire the auto + Running → Unofficial — an open-ended window would never let it.
      +
      Decision
      +
      The grace window (GraceWindow) is either UntilScored (the whole + Unofficial phase) or Duration { micros }. A round defaults to a + bounded 30 seconds (Duration, deliberately not + UntilScored), RD-configurable per round.
      +
      Rationale
      +
      Thirty seconds comfortably covers a trailing pilot finishing the lap they were on when the + leader met the criterion, while still closing on its own so the auto-transition fires. A + bounded default is what makes runtime auto-completion possible at all; UntilScored + remains available where the RD wants to gate the close on scoring instead of a timer.
      +
      + +

      D8 · The qualifying → bracket carry seeds from a ranking's top-N

      +
      +
      Context
      +
      Brackets don't start from the roster — they start from how qualifying ended. The model + needs a clean seam from "a round's ranking" to "a bracket's field" without bolting bracket + logic onto qualifying.
      +
      Decision
      +
      Rounds are event-level and class-tagged, added as-you-go. A bracket round + uses a FromRanking { source_round, top_n } seeding rule: its field is the + top-N of the source round's ranking (via advance_top_n, the same + phase-2 seeding the wholesale run_event does). The full bracket is generated from + that ranking and is then editable as event-level rounds.
      +
      Rationale
      +
      Reusing the existing ranking + advance_top_n means the carry is one seeding + rule, not a new code path — the generator interface and advancement are unchanged. Making the + generated bracket editable lets the RD fix seeds, byes, or a withdrawal before pilots fly, while + everything stays a pure function of the log so the bracket recomputes deterministically.
      +
      Update (2026-07-03)
      +
      The FromRanking UI cut was renamed "Top N advance" → "Take top" + (PR #332), and the value is now bounded by the source rounds' real field — you + cannot take more pilots than the source ranking actually contains.
      +
      Update (2026-07-03) — the carry freezes at first fill (#334)
      +
      A carry seeding (FromRanking / FromRankingRange / + FromHeatWinners / Combine) is resolved once, at the round's + first fill, recorded in the log (Event::RoundFieldDrawn), and every later + read — fills, ranking, standings, dependent seeding — replays the recorded draw. Live + re-resolution let an adjudication on the source round, landing after the dependent + round had raced, silently rewrite who its field "was" (raced results vanished from its + ranking). Before the first fill, resolution stays live (a build-ahead round tracks its source + until it draws), and roster seedings (FromRoster / AllChannels) never + freeze — a late entrant still joins mid-round. The user's call: freeze, not warn.
      +
      + +

      D9 · The native desktop app embeds the Director, ships portable-only, stores data next to the exe — implemented

      +
      +
      Context
      +
      The RD console was always destined for a native shell (Clients; + Architecture §2), shipped as a plain web app in v0.4 while the + Tauri work was deferred. A release form needs to answer three questions at once: how a + non-technical RD gets and runs the app, how the shell relates to the Director that + actually runs races, and where an event's data lives. The hosted Director + (cargo build -p gridfpv-app --features live serving the dist at :8080) + is the fast development loop and must not be disturbed.
      +
      Decision
      +
      The release form is a Tauri 2 desktop app (src-tauri/; PRs + #195, #204) that embeds the Director in-process. On launch it runs the + same gridfpv_app::director::run_director the hosted binary runs, on a + tokio task bound to a loopback ephemeral port (127.0.0.1:0), and + opens a native window pointed at http://127.0.0.1:<port>/. The + frontend is baked into the binary via the embed-assets cargo + feature, so the executable is a single self-contained file. Distribution is + portable-only: the gated release workflow + (.github/workflows/release-builds.yml) builds with --no-bundle and + publishes only the self-contained binary (gridfpv-desktop / + .exe) — the msi/nsis/appimage/deb installers were dropped. Data is stored in a + gridfpv-data/ folder next to the executable (created events' + SQLite files), with a graceful fallback to the per-user app-data dir when the + exe's directory isn't writable (the chosen location is logged on startup). The hosted Director + stays the development/testing path, unchanged; cargo xtask ci excludes + src-tauri (CI runners have no webkit2gtk/GTK).
      +
      Rationale
      +
      Embed the Director because the app is then self-contained and reuses the + exact same core — one run_director serves both the hosted binary and the desktop + shell, so the two can't drift, and loading the Director's own HTTP origin keeps the SPA's + same-origin API and WebSocket calls working with no cross-origin shims. Loopback also means + no auth (per the auth model, loopback = trusted), so the RD opens the window + and is in. Portable-only removes install friction for a club volunteer — there + is nothing to install, no admin rights, no uninstaller; the single file is the app, and it is + trivially movable (copy it to a USB stick). Data next to the exe makes the + build truly portable: copy the exe and its gridfpv-data/ folder together + and the events travel with them — the maintainer's deliberate call, with the per-user app-data + fallback so a read-only mount still works rather than failing. Keeping the hosted Director as + the dev path means the native app is purely additive: it is the release form, + never a replacement for the day-to-day loop.
      +
      Realized (as built)
      +
      Implemented on devel (PRs #195, #204). The shared entry point is + gridfpv_app::director::run_director(addr, data_dir, assets, on_ready, shutdown), + called by both the hosted gridfpv binary (crates/app/src/main.rs) and + the desktop app (src-tauri/src/lib.rs). The desktop crate carries its own + standalone [workspace] so it resolves outside the root spine (which excludes it), + and depends on gridfpv-app with features = ["embed-assets"]. The + release workflow runs on workflow_dispatch and on merge to main, + building --no-bundle and uploading only the portable binary as the sole artifact. + Windows packaging is deferred (the crate is Windows-ready in principle; no artifact is built and + no cross-compile is attempted). The server half is covered headless by + crates/app/tests/director.rs.
      +
      + +
      +

      + The entries below (D10–D14) are decided, not yet built. They settle the + format set, the qualifying-vs-time-trials naming, generate-all-heats, and the bracket round + model. The build follows the marshaling slices (see the + Marshaling Build Plan); these are recorded now so the + plan of record is unambiguous when the work starts. Tracking issues are referenced inline. +

      +
      + +

      D10 · The format set is finalized; ZippyQ is shelved

      +
      +
      Context
      +
      The generator registry has carried seven formats, one of which — + zippyq (rolling "fly when ready") — was only ever a deferred seam: it + proved the dynamic-generator shape (rounds added on demand, see + Race Engine §3) but no surface uses it, and the rolling-queue + engine it would need is out of scope (Qualifying, + Roadmap v0.4 deferrals). Carrying an unused, half-wired format in + the active set muddies the format picker and the build plan.
      +
      Decision
      +
      The active format set is fixed at six: Open Practice · Time Trials · Single + Elimination · Double Elimination · Multi-Main · Round Robin. ZippyQ is + shelved — removed from the active set for now. It remains re-addable later (the + generator code and its honesty-check rationale stay on the shelf, not deleted); it simply + leaves the picker and the plan until a rolling surface actually needs it.
      +
      Rationale
      +
      The set should name what GridFPV actually offers. ZippyQ was a seam, not a feature — keeping + it "active" implied a rolling-queue capability that does not exist. Shelving it (rather than + deleting) keeps the deferred seam honest: the option is preserved without pretending it ships. + (Tracking: #218 — rename Time Trials + remove ZippyQ from the active set.)
      +
      Update
      +
      Reframed by D17: the flat format set collapses to three + RD-facing round types (Practice / Time Trial / Head-to-Head); Round Robin, the elim brackets and + Multi-Main become tournament structures over Head-to-Head, surfaced through builders + rather than the Add-round picker (Multi-Main now shelved there alongside ZippyQ).
      +
      Update (2026-06-30)
      +
      The tournament structures were carved off devel and preserved + on the tournaments-snapshot branch (PR #327); the primitives-first release ships the + three round types — Practice · Time Trials · Head-to-Head — and the structures + will be rebuilt on the hardened base later.
      +
      + +

      D11 · "Qualifying" → "Time Trials": name the format, not the purpose

      +
      +
      Context
      +
      The timed_qual format carries the friendly name "Qualifying". But + "qualifying" names a purpose — to qualify, to produce a seeding — not a + format. The format itself is individual-best-ranked heats: every pilot flies their own + best flight and is ranked on it. That is time-trial scoring, and a round can be a time + trial without its output ever feeding a bracket.
      +
      Decision
      +
      The format's friendly name becomes "Time Trials" (the engine key + timed_qual is unchanged). The format is the time-trial scorer; qualifying + is one thing you do with it. It pairs with the round's win-condition metric exactly as + before (the metric is derived from the win condition, D6). The + MultiGP Global Qualifier is then named precisely as + Time Trials + Best 3 Consecutive. The + "Heats per pilot" param (key rounds, default 3) stays as-is.
      +
      Rationale
      +
      Naming the format for what it is (not what it's for) makes the picker + honest and lets a Time-Trials round stand alone — a practice-style ranked session, a GQ + leaderboard, or the seeding for a bracket — without the name presupposing the bracket. It also + makes "GQ = Time Trials + Best 3 Consecutive" a clean, composable statement rather than a + special case. (Tracking: #218.)
      +
      + +

      D12 · "Generate heats" fills the whole round at once

      +
      +
      Context
      +
      Heat-filling has a per-heat primitive: the backend's FillRound appends the + next heat and acks whether the round merely appended a heat or is now + complete. For the deterministic formats — Time Trials, Round Robin, Multi-Main, and the + bracket structure — the whole round's heats are a pure function of the field, so clicking + "fill next heat" repeatedly to lay down a round the engine could compute in one pass is + busywork.
      +
      Decision
      +
      A single "Generate heats" action fills the whole round at + once: the backend loops the generator (calling the existing + FillRound step until the round reports complete) and the UI gets one filled round. + It applies to the deterministic formats (Time Trials, Round Robin, Multi-Main, + and the bracket structure). The incremental "next heat" path remains only + for inherently dynamic formats — Open Practice — where the next heat depends on + who shows up, so there is nothing to compute ahead.
      +
      Rationale
      +
      It reuses the primitive that already exists: FillRound already distinguishes + appended-vs-complete, so "generate the round" is just a backend loop over it, not a new code + path. The deterministic formats know their full shape up front, so one action should produce it; + keeping incremental fill for Open Practice matches the one format whose heats genuinely can't be + computed in advance. (Tracking: #216.)
      +
      Update (2026-06-30)
      +
      The tournament structures named here (Round Robin, Multi-Main, the bracket structure) were + carved to tournaments-snapshot (PR #327); the primitives-first release ships the + three round types, to which generate-heats applies as built.
      +
      + +

      D13 · Brackets: one round per bracket level (supersedes one-round-per-bracket)

      +
      +
      Context
      +
      The original carry (D8) generated a bracket as one round for the whole bracket, + with intra-round advancement logic walking the bracket tree inside that single round. That makes + the bracket round a special, internally-stateful shape unlike every other round in the model.
      +
      Decision
      +
      Each round is one bracket level — Quarters, Semis, Final are three rounds, + seeded round-to-round via the existing FromRanking rule (D8). This + supersedes the one-round-per-whole-bracket structure. + Single elimination becomes a clean chain of winners-rounds: each level seeds + from the prior level's ranking, no intra-round bracket-walking. Double + elimination is the design problem: a losers-bracket round is fed by both the + prior LB round's winners and the current WB round's losers (a cross-bracket, + "losers-of" feed), so FromRanking must grow from single-source top-N into + multi-source winner/loser selection. Single-elim ships first; + double-elim seeding is designed carefully before it's built.
      +
      Rationale
      +
      One level per round is more uniform with the round model — a bracket becomes an ordinary + chain of rounds, each a pure function of the previous round's ranking — and is likely simpler + per round because it removes the intra-round advancement machinery. The cost is concentrated and + named: double-elim's cross-bracket feed is the one place the seeding rule must genuinely grow, so + it's called out as the design problem and deferred until that growth is designed rather than + improvised. (Tracking: #217, which notes it supersedes one-round-per-bracket.)
      +
      Update (2026-06-30)
      +
      The bracket builders and level-per-round structures were carved to + tournaments-snapshot (PR #327); the primitives-first release ships the three round + types, and brackets will be rebuilt on this model post-v1.
      +
      + +

      D14 · Double-elim and MultiGP Multi-Main are first-class next-iteration must-haves

      +
      +
      Context
      +
      The format set (D10) includes Double Elimination and Multi-Main, but they are the two with + genuine design depth left. They must not slip into "someday" — they are the formats serious + chapters expect.
      +
      Decision
      +
      Double elimination (its cross-bracket losers-of seeding, D13) and + MultiGP Multi-Main are first-class, next-iteration must-haves. + Multi-Main is distinct from elimination: it is a finals split by qualifying rank + — A/B/C-mains racing in parallel for placement within their tier, with + bump-ups between tiers — so it gets its own design, not a + reuse of the elimination chain.
      +
      Rationale
      +
      Both are table-stakes for the formats GridFPV claims, and both carry real model work + (double-elim's seeding, multi-main's parallel-tier-with-bump-up structure) that shouldn't be + improvised under race-day pressure. Flagging them as next-iteration must-haves keeps them on the + near plan with the design done up front. (Tracking: #219 — Multi-Main as a + first-class finals format.)
      +
      Update (2026-06-30)
      +
      Both structures (with the rest of the tournament work) were carved to + tournaments-snapshot (PR #327); the primitives-first release ships the three round + types, and double-elim / multi-main are rebuilt as structures over Head-to-Head post-v1.
      +
      + +

      D15 · GridFPV native timer-server (post-v1.0.0): reuse RH firmware, strip the RMS superseded

      +
      +

      + Superseded by D16 (2026-06-26). The own-the-stack + timer-server is dropped in favor of a required GridFPV RotorHazard + plugin. The plugin delivers what owning the server was for — live dense + RSSI, clean start/stop, per-node passes, and threshold/calibration access (which unblocks + re-detection marshaling) — far more cheaply, by running inside the RH server with + RHAPI access and reusing RH's entire proven stack (no RMS replacement, no firmware + re-hosting). The text below is retained as history. (Note: D16 also revises D15's premise + that GridFPV must never gate on a GridFPV timer — see D16's rationale.) +

      +
      +
      +
      Context
      +
      GridFPV depends on RotorHazard as both the timer and the + race-management system (RMS). RH only streams scalar RSSI aggregates + on the wire — the dense per-tick signal history needs a post-race pull — and we don't control + that wire protocol. So the richest signal, the one marshaling most wants, never arrives live and + isn't ours to shape (Timer Adapters §3, + Marshaling).
      +
      Decision
      +
      A two-tier timer strategy. Tier 1 (now, non-negotiable): + the RH adapter stays the primary path — it works with any existing RH install + at zero friction (the adoption path), and we never gate GridFPV on a GridFPV timer. + Tier 2 (post-v1.0.0): a lightweight, fully remote-controlled GridFPV + timer-server on the same Pi + RX5808 hardware that + reuses the RH node firmware (the proven RX5808 driver + node-level RSSI + crossing detection) and strips the RH RMS — replacing only the server/RMS + layer. Same hardware, software swap.
      +
      Rationale
      +
      The RX5808 driver is the easy part; RH's real moat is the detection — the + hysteresis, calibration, and start-of-race tuning — so we reuse the node firmware rather than + re-earn it. Owning the server gives us the dense signal natively (it solves the + marshaling fidelity gap rather than working around it) and enables full re-detection + marshaling (the deferred RH-style recalculate). The mock-timer protocol is a + first draft of this server's wire protocol. Sequence after the core RMS + + marshaling are proven; this is post-v1.0.0, not a near-term dependency — Tier 1 carries + us until then. (Tracking: #238.)
      +
      + +

      D16 · Require a GridFPV RotorHazard plugin; drop own-the-stack (supersedes D15)

      +
      +
      Context
      +
      D15 committed to owning the server half of the stack — a post-v1.0.0 native timer-server + reusing the RH node firmware and stripping the RH RMS — to get the dense per-tick + signal natively and unblock full re-detection marshaling. That is real engineering: + a firmware re-host, an RMS replacement, and the standing risk of having to prove our detection + is as good as RH's. Meanwhile the live integration kept accreting socket-API scar + tissue: the save-then-pull dense-signal path, the alter_race_format + staging-zero hack, the add_pilot/alter_heat seating dance, and an + un-zeroable ~0.9 s prestage — all symptoms of the RH socket protocol being too + limiting, not of RH itself.
      +
      Decision
      +
      Require a GridFPV RotorHazard plugin; drop the own-the-stack timer-server. + The plugin runs inside the RH server with RHAPI access to the node + manager, race events, and config, and becomes the primary RH integration. It + delivers exactly what owning the server was forlive dense RSSI (no + save-then-pull), clean start/stop bypassing the RMS staging, + per-node passes without the seating dance, and + threshold/calibration access (which unblocks the draggable-threshold + "recalculate" re-detection marshaling feature, #3) — while reusing RH's entire proven + stack (node firmware + RMS + the detection moat): no RMS replacement, no firmware + re-hosting, no "is our firmware as good as theirs" risk. "Required" means a guided + install — Grid detects a missing plugin and offers a one-step install — not a + cryptic hard-fail, so the "try it fast" feel is preserved. The plugin makes the socket + workarounds native, so that workaround code (the dense-signal save-then-pull, the + alter_race_format hack, the seating dance, the prestage fight) can be + retired. This revises D15's premise that GridFPV must never + gate on a GridFPV timer (see rationale). The full plugin design doc + sliced build plan + are forthcoming separate work; this entry records the direction, not the full design.
      +
      Rationale
      +
      Friction is a non-issue. Every other serious RMS — FPVTrackside, LiveTime — + already requires its own timing plugin/install, so requiring ours is the normal shape + of a serious RMS, not a barrier. That fact revises the prior "zero-friction stock RH / never + gate on a GridFPV timer" principle (D15): a guided one-step install is not friction worth + designing the whole stack around. The plugin is far cheaper than owning the stack + and gets the same payoff: it inherits RH's detection moat instead of re-earning it, and its + RHAPI access yields the dense signal and the clean control surface the socket API can't. + The socket API is the actual problem — proven by the scar tissue above — and the + plugin makes those paths native rather than worked-around. Net: we keep RH's proven hardware and + detection, gain the fidelity and control we wanted, retire the workaround code, and unblock + re-detection marshaling — without a firmware project. The mock-timer protocol remains a useful + reference, but the RH integration path is now the plugin.
      +
      + +

      D17 · Round types & scoring: Practice / Time Trial / Head-to-Head, structures over rounds (refines D10/D11/D14)

      +
      +
      Context
      +
      The format set (D10) is a flat zoo — timed_qual, round_robin, + single_elim, double_elim, multi_main, + chase_the_ace, open_practice — which tangles three different things into + one "format" knob: the kind of flying, how a heat is won, and how a + round ranks. An RD thinks in fewer, cleaner buckets.
      +
      Decision
      +
      The RD-facing model is three round typesPractice, + Time Trial, Head-to-Head — with two orthogonal knobs: + win condition (wins a heat — First-to-N / Most-laps / Best-of-N) and + scoring (ranks a roundPoints via a customisable + per-position table, or Placement). Round-robin and brackets are not + formats — they are tournament structures assembled from Head-to-Head + rounds (round-robin = Points + all-play-all; bracket = Placement + elimination chain), buildable + by hand and automated by builders. Best-Lap folds into Best-of-N (N=1); Practice is one + channel-based format, optionally named after a class. Full spec: + Format Model.
      +
      Rationale
      +
      Separating racing type from structure (and win condition from + scoring) matches how RDs reason — "practice / qualify / race", then "how do I bracket + them". Key tenet: all racing is built from the atomic formats; + a tournament structure composes format rounds to assemble itself and never + reimplements racing. The engine is refactored to honour this — one racing + generator (Head-to-Head), with round-robin / brackets / multi-main / chase-the-ace driving it — + rather than a silo generator per structure (so round_robin / single_elim + dissolve into Head-to-Head + composition). Refines D11 (Time-Trial naming) and reframes D10/D14. + Rolls out: bracket win-condition picker (shipped) → Head-to-Head building block → recompose + round-robin → recompose the bracket → taxonomy collapse; the console keeps legacy names until + each stage ships. Full spec: Format Model.
      +
      Update (2026-06-30)
      +
      The rollout's structure stages (recompose round-robin, recompose the bracket, build + tournament) are deferred: the tournament structures were carved off + devel to the tournaments-snapshot branch (PR #327). The + primitives-first release ships the three round types — + open_practice (Practice, renamed in the UI from "Open Practice"), + timed_qual (Time Trials), head_to_head + (Head-to-Head) — and the structures are rebuilt on the hardened base later.
      +
      + +

      D18 · One grouping decision per round (extends D17)

      +
      +
      Context
      +
      With Head-to-Head shipped as a primitive (D17), the question arose whether a round should be + able to re-group its pilots between heats — fly a pass, re-draw the groups, fly again. + That would smuggle seeding logic (a structure concern) into a round param, and would make a + round's heats no longer a single, reviewable draw.
      +
      Decision
      +
      A Head-to-Head round makes one grouping decision, at fill (2026-07-03, + PR #332). The new rotations param (labeled "Heats per group", + default 1) re-runs the same groups back to back: with + rotations = N, each group races N consecutive heats. Scoring accumulates across + every heat flown — Points sums a pilot's points over all their heats; + Placement reads the best single result. Re-grouping between heats is + seeding work and belongs to tournament structures, never a round param. Multi-rotation + heat ids are h2h-r{n}-h{m}; single-pass rounds keep h2h-h{n}. (In the + log every generator id is additionally round-scoped{round}-… — + so two rounds of the same format in one event never collide on heat ids.) + Alongside this, the FromRanking UI cut was renamed + "Top N advance" → "Take top" and is bounded by the source rounds' real + field.
      +
      Rationale
      +
      One grouping decision keeps a round a pure, reviewable draw — the RD sees the groups once + and knows exactly what will fly — and keeps the primitive atomic per the D17 tenet: anything + that re-draws groups between heats is composition, so it lives in the structures layer + (rebuilt post-v1), where seeding is explicit and auditable. rotations covers the + legitimate round-level need (more track time per group, points that reward consistency) without + opening the seeding door. Full round-type spec: + Format Model.
      +
      + +

      D19 · A timed heat always ends on the wall clock (fallback beside the pass-based criterion)

      +
      +
      Context
      +
      The completion clock ended a Timed heat via the pure predicate + race_end_reached, which needs a counted crossing at or after the window + close — the observable, source-clock signal that the window elapsed. But when every pilot + lands at the buzzer, no such crossing ever arrives, and the heat ran forever (caught live: + a 30-second time trial still "Running" hours later). The same audit found the degenerate + configs that inverted the problem — a zero window, First-to-0, a zero time limit — ended + heats at the start signal.
      +
      Decision
      +
      The completion driver also holds a wall-clock deadline: window + + grace, anchored to the first observed pass (race-go when nobody ever crossed). + Whichever fires first — the pass-based criterion or the deadline — appends the same + Finished. Degenerate end-condition values are rejected at round + create/update. Alongside this, Discarded joined Aborted/ + Restarted as a run-reset boundary, so a discarded run's laps stop rendering (and + re-scoring) the moment the RD discards.
      +
      Rationale
      +
      The pass-based criterion stays primary (it is pure and replayable — the deadline is + runtime-only, like every clock decision per §6 of the Race Engine); the fallback anchors to + the first observed pass so it can fire late but never early. A race that cannot end + is a race-day stopper; a heat that ends at the start signal is worse. Both now impossible.
      +
      + +

      D20 · Passes carry a heat tag; attribution is by tag, position is legacy

      +
      +
      Context
      +
      Raw passes carried no heat id: every fold attributed them positionally — a pass + belongs to whichever heat's span is open at that log offset. The release audit traced the + failure family that follows: scheduling or filling another round mid-race closes the + running heat's span, silently dropping every later lap from its live view and its result; + marshaling a finished heat mid-race could steal the cursor the same way; selecting an old + heat as current absorbed every later heat's passes.
      +
      Decision
      +
      Pass gains an optional heat tag, stamped by the Director's + bridge sink at append time (the adapter doesn't know the heat; the bridge + routes passes only while a heat is Running, so it does). Every fold — heat window, class + window, live state, the completion clock — attributes a tagged pass by its + tag; an untagged pass (a legacy log) keeps the positional rule, so old logs replay + unchanged.
      +
      Rationale
      +
      The tag is recorded where the knowledge lives (the router, not the adapter), keeps the + fold pure, and removes the whole "heat-span event mid-race" bug class instead of gating the + commands that could trigger it — the RD may now fill the next round while a heat races, + which is exactly what a busy race day wants.
      +
      + +

      D21 · Runtime clocks are per-heat, never replayed, and re-check state at fire time

      +
      +
      Context
      +
      The runtime clock tracked ONE start, ONE completion, and ONE protest driver — but the + windows genuinely overlap in normal operation (heat 2 finishes while heat 1's protest window + is open). Installing the newer driver silently detached the older task: cancellation + could never find it again, and the orphan later force-finalized a heat the RD had discarded. + Separately, the bridge replayed the whole log on startup — re-firing historical drivers and + sim sources on every Director restart — and its cancel-by-poll left a ~200ms window where an + expiring driver's stale transition landed after a manual command.
      +
      Decision
      +
      Drivers are keyed per heat (a map, not a slot). The bridge starts from + the log tail — history is never replayed — and any heat currently + Armed/Running/Unofficial is handed once to the normal + transition handler, so a mid-race Director restart mints fresh clocks over current state + (the race still auto-ends; the RD gets new race clocks, per the user-approved policy). Every + driver re-checks the heat's folded state at fire time (under D22's lock) + before appending; a transient log read error retries instead of ending the event's runtime.
      +
      Rationale
      +
      The engine fold deliberately accepts any recorded transition ("legality is apply's + job"), so the runtime must never leak a stale append — belt (cancellation that can actually + find its task), braces (fire-time recheck), and no replays to leak from in the first + place.
      +
      + +

      D22 · Validated writes are serialized: validate→append is one atomic step

      +
      +
      Context
      +
      Command validation read the log, then appended — two separate lock acquisitions. + Concurrent appenders exist by design (the runtime drivers, a second console), so every gate + had a TOCTOU window: a ruling could land on a heat that went Final in between (the + auto-official racing the RD), Finalize could slip past a fresh protest, two + ScheduleHeats could both pass the fresh-id check.
      +
      Decision
      +
      A per-event command serialization lock spans the whole + validate→append sequence for every control command (including the multi-step fill / + advance / schedule paths) and for every runtime driver's checked append + (AppState::append_checked). Raw pass appends bypass it — they validate + nothing. Lock ordering is fixed: command lock before log lock, never the reverse.
      +
      Rationale
      +
      Human-scale command traffic doesn't need finer concurrency; it needs the invariants the + gates promise to actually hold. One coarse lock is obviously correct and unnoticeable at + race-day rates (the user's explicit trade: correct over clever).
      +
      + +

      D23 · A protest dies with the run it contests; rulings apply effectively once

      +
      +
      Context
      +
      The open-protest predicate (which gates Finalize, manual and auto) counted + filings over the whole log, but the audit/lap views window per run — so a protest filed + before a Restart blocked finalization forever while no view could show it: an + RD deadlock. Separately, a filing accepted a second resolution (possibly contradictory), and + a heat accepted a second void — after which reversing "the" ruling silently didn't take.
      +
      Decision
      +
      A protest contests a specific run's result: filings before the heat's latest + reset (Abort / Restart / Discard) no longer count — the field re-races + anyway (user-confirmed semantics). A filing takes ONE effective resolution (reverse it to + re-decide); a heat takes ONE effective void (reverse it to re-void).
      +
      Rationale
      +
      The predicate and the views now window by the same boundary, so what gates Finalize is + always visible; idempotent rulings keep the reversal chain meaningful (undoing a ruling must + actually undo it).
      +
      + +

      D24 · A raced round's scoring config is frozen (the heats-per-pilot count stays open)

      +
      +
      Context
      +
      Scoring re-derives from a round's current config on every read — by design + (pure folds). But update_round replaced the round wholesale with no look at the + log, so editing a raced round's win condition retroactively re-scored already-official heats + (a config-side bypass of the Final lock), re-seeding could rewrite a bracket chain, and + deleting a round stranded its heats. The console's Edit form compounded it by collapsing + any seeding it couldn't model to FromRoster on save.
      +
      Decision
      +
      Once a round has a heat beyond Scheduled, its format, classes, win + condition, seeding, channel mode, and format params are frozen — except the + rounds (heats-per-pilot) count, which only extends future fills + (user-specified carve-out). Label, staging timer, start procedure, grace, protest window, + and race time stay editable. A round with any heats cannot be removed. The form preserves + unmodeled seedings verbatim. Declared format params are validated at create/update, so + garbage fails at the form, not at fill time.
      +
      Rationale
      +
      Pure re-derivation is only trustworthy if the inputs an official result derived from + can't drift underneath it. Freezing beats re-scoring guards: the RD keeps the race-day knobs + and loses only the edits that would silently rewrite history.
      +
      + +

      D25 · The removal record: voids and re-detection share one truth; rulings are run-scoped

      +
      +
      Context
      +
      Threshold re-detection reads the RSSI trace; a marshal's DetectionVoided + reads the log. The two never met: the RD removes a valid-crossing-but-not-a-lap detection, + and the tuner — seeing the crossing plain as day on the trace — keeps proposing it back as + "a lap to add" (field report, Audit Shakedown). The deep-dive fuzz then showed the wider + family: rulings accepted against abandoned runs, stacked duplicates that made reversal a + no-op, degenerate instants that hijacked the race clock, and no way to undo a void at all.
      +
      Decision
      +
      The lap list carries each competitor's voided passes (raw instant + + pass offset + the standing void's offset) as first-class projection data. Re-detection + SUPPRESSES unmatched crossings near a voided instant — matching runs first, so a void can + never claim a surviving pass's crossing. Restore (void-the-void) is a + sanctioned command path with a button on the removal record. And the command layer is + run-scoped and effectively-once: targets from abandoned runs are rejected, + protests/heat-voids need a current run, one standing throw-out per lap and one standing DQ + per pilot per heat, and inserted/re-timed instants must be sane (positive, bounded, never + colliding into a zero-duration lap).
      +
      Rationale
      +
      An adjudication is a statement about a specific observation in a specific run — every + surface that can see the observation must see the ruling too, and a ruling that applies to + nothing (stale run, duplicate, phantom instant) must be refused at the door rather than + recorded as an ack-ok lie. Extends D22/D23's serialization + effectively-once discipline to + the whole correction vocabulary.
      +
      + +

      D26 · Timers are dumb emitters: lap semantics — including the minimum-lap floor — live in GridFPV

      +
      +
      Context
      +
      A start-line double-detection gave every pilot a phantom 0.004s "lap 1" (Audit + Shakedown). RotorHazard HAD MinLapSec=10 configured — but its default + behavior is highlight-don't-discard, so the sub-min lap was recorded and forwarded with + no invalid marker. Any timer, any config drift, same hole: relying on the timer to + enforce lap semantics means the results depend on someone else's settings screen.
      +
      Decision
      +
      Timers emit observations (passes, signal); GridFPV owns lap + semantics. Each round carries an optional min_lap_secs floor (the + form seeds 5s for new rounds; 0/absent = off, so pre-existing rounds keep bit-identical + results). The corrected-passes fold AUTO-SUPPRESSES a raw pass that would close an + under-floor lap — applied identically in the lap list, the live view, and every scoring + path, and surfaced on the marshaling removal record as "under min lap, auto-removed" + with a Restore override. Marshal-created passes (inserts, + split-synthetics) and re-timed passes are EXEMPT: an explicit ruling always outranks + the floor — and Restore is exactly that ruling (an AdjustLap re-asserting + the pass's raw instant). The floor freezes once the round has raced (D24: it re-scores + official heats). The log keeps every raw pass — suppression is a view-fold rule, never + data destruction.
      +
      Rationale
      +
      Defensible results require the enforcement point to be the system of record, not an + upstream device. Folding the floor once, beneath every projection, keeps the lap list + and the score incapable of disagreeing; recording everything keeps the evidence; the + marshal override keeps a whoop track's genuine 2-second laps scoreable.
      +
      + + +
      + + + + diff --git a/docs/event-pipeline.html b/docs/event-pipeline.html index 13005b8..a6d6558 100644 --- a/docs/event-pipeline.html +++ b/docs/event-pipeline.html @@ -31,6 +31,24 @@

      Event Pipeline

      +
      +

      + As built (v0.4) — the staged event workspace. The phase model below is the + conceptual intent. In the shipped console an event is a sequence of editable + stage-pagesClasses + Roster (combined), + Rounds + Heats, Live control, Marshaling, + Results — plus a guided setup wizard that walks them + (Next-only; selections auto-save, no Save buttons). There is no + separate “Setup” tab. The phases map onto rounds: everything an + event flies is a round on the event with one of the three round-type formats + (Practice, Time Trials, Head-to-Head — the primitives-first + release, PR #327), each round targeting one class. The tournament phase (Phase 4, + Brackets / Mains) is deferred to the post-v1 rebuild, preserved on + the tournaments-snapshot branch. See the deep dives for what actually shipped + vs. what is still planned. +

      +
      +

      Overview — the model

      An event is not a straight line. It is a mostly-linear progression of @@ -134,6 +152,7 @@

      Formats (when run live)

      Phase 4 — Brackets / Mains

      the racing Seed, build the bracket, run it, crown a winner.

      +

      Deferred (2026-06-30): this phase is carved out of the primitives-first release — preserved on tournaments-snapshot (PR #327), to be rebuilt as tournament structures over Head-to-Head rounds post-v1.

      This is the competition proper. It begins by turning the seeding into a structure, then runs that structure to a result. diff --git a/docs/format-model.html b/docs/format-model.html new file mode 100644 index 0000000..da57e0c --- /dev/null +++ b/docs/format-model.html @@ -0,0 +1,193 @@ + + + + + + GridFPV — Format Model + + + +

      +
      +

      GridFPV

      +

      Format Model

      +

      Internal design doc · the round-type / scoring taxonomy and how tournaments are structured

      +
      + +

      + How an RD describes what kind of flying a round is — collapsed from a zoo of + per-format generators into three round types (Practice, Time Trial, + Head-to-Head) plus two orthogonal knobs (win condition, which wins a + heat, and scoring, which ranks a round). Round-robin and + brackets stop being "formats" — they are tournament structures assembled from + Head-to-Head rounds, by hand or with a builder. +

      + +
      +

      + This is the target model (decision D17). It + supersedes the per-format framing in the earlier heat-gen/formats decisions (D10/D11/D14): the + engine still has generators per mechanic, but the RD-facing vocabulary is the three + round types + scoring + structures. It rolls out in stages (see Rollout); + until a stage ships, the console still exposes the legacy format names. +

      +

      + Release state (2026-06-30, PR #327) — this release is primitives-first. The + three round types shipped; the tournament structures (§4), the structure half + of the engine refactor (§5), and rollout items 3, 4, and 6 describe deferred work + carved off devel and preserved on the tournaments-snapshot branch, to + be rebuilt on the hardened base post-v1. In the UI, "Open Practice" was renamed + "Practice" (the wire key open_practice is unchanged). +

      +
      + +
      +

      + Core tenet — all racing is built from atomic formats. The three round types + (Practice, Time Trial, Head-to-Head) are the building blocks; a tournament + structure (round-robin, single/double elimination, multi-main, …) never reimplements + racing — it composes atomic format rounds to build its structure. The + workflow is: build the formats, then a structure leverages those formats to assemble + itself. The engine is refactored to honour this — one racing generator (Head-to-Head), + with structures driving it — rather than a silo generator per structure. This is a key tenet + of how we build, not just a format menu. +

      +

      + Corollary — one grouping decision per round (decision + D18, 2026-07-03): a Head-to-Head round draws its groups + once, at fill. The rotations param ("Heats per group") re-runs the + same groups back to back, with scoring accumulating across the heats flown + (Points sums every heat; Placement reads the best single result). Re-grouping between + heats is seeding work — it belongs to tournament structures, never to a round param. +

      +
      + +

      1 · Three round types

      +

      The Add-round picker offers exactly these. Everything an RD flies is one of them:

      + + + + + + + + + + + + + + + + + + + + + + + + +
      Round typeWhat it isScoringKnobs
      PracticeFly, no competition. Pick channels; pilots hop on and coordinate.nonechannels; optional time limit; named "Open Practice" or "‹Class› Practice" (a label only — no mechanical difference)
      Time TrialQualify against the clock on fixed channels; your best result ranks you.best result ranks the fieldBest of N laps (N configurable; N = 1 is "best lap") + a race time
      Head-to-HeadRace other pilots directly, in heats.Points or Placement (§3)group size (2+); a per-heat win condition; a scoring system; rotations ("Heats per group", default 1 — the same groups race N back-to-back heats)
      +

      + Best-Lap folds into Best-of-N (N = 1), so Time Trial has a single ranking metric rather + than two. Practice is one format; the "class" variant is purely the round/heat name. +

      + +

      2 · Two orthogonal knobs: win condition vs scoring

      +

      The thing that used to be tangled inside "format" splits cleanly in two:

      +
        +
      • + Win condition — how a single heat is decided. For racing that is + First to N laps or Most laps in N minutes (Timed). For a + Time Trial it is the qualifying metric (Best of N). First-to-N self-terminates (no race time); + Most-laps and Best-of-N need a race time. +
      • +
      • + Scoring — how a round's heats become a ranking (only Head-to-Head + has a choice; see §3). +
      • +
      +

      + So a Head-to-Head round carries both: the win condition resolves each heat's finish + order, and the scoring system turns those finishes into the round's ranking. +

      + +

      3 · Scoring systems (Head-to-Head)

      +
        +
      • + Points — finish position earns points from a customisable + per-position table (1st always the most, strictly descending; steep / exponential + drop-offs are allowed). All of the round's heats are scheduled up front; + points accumulate across a pilot's heats; at the end, most points wins. This + is what makes a round-robin rank correctly. +
      • +
      • + Placement — ranked by where you finished / how far you advanced (winners + move on). This is what a bracket uses. +
      • +
      +

      + The scoring selector lives on the Head-to-Head round, so an RD can hand-build a points + group-stage or a placement round without any builder. +

      + +

      4 · Tournament structures

      +

      + Round-robin and brackets are not formats — they are structures assembled from + Head-to-Head rounds. An RD can build them by hand; the builders are + automation/helpers for the common shapes: +

      +
        +
      • Round Robin = Head-to-Head + Points + all-play-all, a single round (all heats up front, most points wins).
      • +
      • Bracket (Single Elimination) = Head-to-Head + Placement + an elimination chain (one round per level, winners seeded forward). The bracket builder names the levels "‹Bracket› — ‹Level›" and picks one win condition for all the bracket's heats.
      • +
      • Double Elimination, Multi-Main, Chase-the-Ace final = further structures over Head-to-Head — deferred (single-elim first).
      • +
      + +

      5 · Engine refactor (structures compose the building block)

      +

      + Honouring the core tenet, the engine is refactored rather than re-labelled: one + racing generator (Head-to-Head) becomes the building block, and the bespoke + per-structure generators dissolve into Head-to-Head + composition: +

      +
        +
      • Head-to-Head — the new atomic racing generator: group size, per-heat win condition, scoring (Points with a custom table / Placement). It races a field and ranks it — nothing else.
      • +
      • Round Robin — recomposed: the rotation + points aggregation that lives in round_robin today becomes a structure driving Head-to-Head (all-play-all passes, Points). round_robin is retired into this composition.
      • +
      • Single-Elim Bracket — recomposed: a chain of Head-to-Head (group 2, Placement) rounds; the 1-v-8 pairing becomes seeding the builder applies and "winners advance" is the existing FromHeatWinners carry. single_elim's bespoke pairing/advancement folds into Head-to-Head + the builder.
      • +
      • Time Trialtimed_qual (relabel; Best-Lap → Best-of-N). Practiceopen_practice (+ optional class name).
      • +
      • Double-Elim / Multi-Main / Chase-the-Ace — further structures over Head-to-Head (Chase-the-Ace is already structure-shaped: repeated Head-to-Head races, first-to-N wins).
      • +
      +

      + Net: the structures leverage the atomic formats to assemble themselves, exactly per the + tenet — the engine ends with one racing mechanic plus composition, not a silo per structure. +

      + +

      6 · Rollout

      +
        +
      1. Bracket win-condition picker — the "Advance to bracket" modal picks one win condition for all the bracket's heats (default First-to-N; option Most-laps). Shipped.
      2. +
      3. Head-to-Head building-block generator — the atomic racing format: group size, per-heat win condition, scoring (Points with a custom table, or Placement). Built first, because the structures compose it.
      4. +
      5. Recompose Round Robin over Head-to-Head (all-play-all + Points), retiring the bespoke round_robin generator. Deferred — carved to tournaments-snapshot (PR #327); rebuilt post-v1.
      6. +
      7. Recompose the Bracket over Head-to-Head (Placement + seeding + the existing FromHeatWinners chain), folding away single_elim. Deferred — carved to tournaments-snapshot (PR #327); rebuilt post-v1.
      8. +
      9. Taxonomy collapse — Add-round becomes Practice / Time Trial / Head-to-Head (ordered Open Practice → Time Trials → Head-to-Head); Time Trial → Best-of-N (one metric, N=1 = best lap); Head-to-Head carries group size (a dropdown capped at the primary timer's node count), per-heat win condition (Timed / First-to-N only), and scoring (Placement, or Points with a per-position editor that follows the group size). Hand-built heats cap their lineup at the node count. Shipped.
      10. +
      11. Build tournament — one action that assembles a structure (single/double elim, round-robin, multi-main) from a seed source: either a prior round's ranking or the roster/pilot list (so you can bracket without qualifying). Its rounds + heats appear in the normal Rounds / Heats area. Double-elim's grand final is the normal final choice (single race / Chase-the-Ace), not a bracket reset. Deferred — carved to tournaments-snapshot (PR #327); rebuilt post-v1.
      12. +
      +

      + User-facing docs (the guide's Formats page) track shipped behaviour, so they are + updated per stage as each lands — not ahead of the build. +

      + +

      7 · Future work — the Tournament view

      +

      + Ideas to keep in mind once Build-tournament lands (not yet scheduled): +

      +
        +
      • Rename the bracket container to Tournament — it holds any structure (round-robin, multi-main, double-elim), not just a bracket.
      • +
      • Give a tournament its own page: edit all its config in one place, see the visualization, and click a round or heat to pop its existing edit modal.
      • +
      + + +
      + + diff --git a/docs/heat-lifecycle.html b/docs/heat-lifecycle.html new file mode 100644 index 0000000..939d967 --- /dev/null +++ b/docs/heat-lifecycle.html @@ -0,0 +1,316 @@ + + + + + + GridFPV — Heat Lifecycle + + + +
      +
      +

      GridFPV

      +

      Heat Lifecycle

      +

      Internal design doc · the heat FSM, its commands, and the runtime clock that drives it

      +
      + +

      + The one reusable state machine that runs every heat, in every phase — six states from + Scheduled to Final. This is the deep-dive behind + Race Engine §2: the exact legality table, the two + runtime-driven forward steps (the start procedure and the grace-window completion), the + off-ramps, and how all of it stays a deterministic, replayable fold of the log. +

      + +
      +

      + This doc owns the heat finite-state machine — its states, commands, + recorded transitions, and the runtime clock that appends the automatic ones. It builds + on the append-only spine (Architecture §3), the scoring + and win conditions (Race Engine §4), and the canonical + passes from Timer Adapters. The why behind + each decision here is logged ADR-style in Decisions. When + this doc and the Vision disagree, the Vision wins. +

      +
      + +

      1. The six phases

      +

      + A heat moves through six states. A HeatScheduled event seeds it at + Scheduled; every move after is a HeatStateChanged carrying a + HeatTransition; Advance hands the result to the format generator + and leaves the machine. The pure FSM lives in crates/engine/src/heat.rs. +

      + +
      +stateDiagram-v2
      +  [*] --> Scheduled
      +  Scheduled  --> Staged     : Stage
      +  Staged     --> Armed      : Start (runs the start procedure)
      +  Armed      --> Running    : (auto) countdown elapsed / SkipCountdown
      +  Running    --> Unofficial : (auto) win condition + grace / ForceEnd
      +  Unofficial --> Final      : Finalize
      +  Final      --> Unofficial : Revert
      +  Final      --> [*]        : Advance
      +  Staged     --> Scheduled  : Abort
      +  Armed      --> Scheduled  : Abort / Restart
      +  Running    --> Scheduled  : Abort / Restart
      +  Unofficial --> Scheduled  : Restart
      +  Final      --> Scheduled  : Discard
      +    
      + +
      +
      Scheduled
      +
      Created with a lineup (and its class / round tag and per-pilot frequency + assignments), not yet staged. The entry state a HeatScheduled event creates, + and the state every off-ramp resets to.
      +
      Staged
      +
      The staging countdown is underway; for IRL this is when frequencies / channels are + assigned. The staging timer is informational only — there is no + auto-advance out of Staged; the RD presses Start when ready.
      +
      Armed
      +
      The gate is open to detections and the start procedure is running + (announce → randomized hold → tone). The runtime auto-advances to Running + when the hold elapses.
      +
      Running
      +
      The race is live; passes are consumed from here (plus the grace window). The runtime's + completion clock watches the win condition and auto-advances to Unofficial.
      +
      Unofficial
      +
      The race has closed but the result is not yet locked — the grace + window for late crossings is still open and marshaling / corrections may land. Named + Unofficial deliberately (not "Scored" or "Completed"): it is a real, displayable + result that is explicitly provisional.
      +
      Final
      +
      The result is finalized and locked. Revert can re-open + it for correction; Advance hands it to the format generator; + Discard queues a clean re-run.
      +
      + +

      2. Commands & the legality table

      +

      + Live race control is just HeatCommands validated against the current state. + Commands are kept distinct from the recorded HeatTransition so the off-ramps + read clearly. The apply(state, command) function is the single source of FSM + legality; next_state(from, transition) only describes where a recorded + transition lands. The complete table: +

      + + + + + + + + + + + + + + + + +
      CommandLegal inRecordsLands in
      StageScheduledStagedStaged
      StartStagedArmedArmed
      SkipCountdown (override)ArmedRunningRunning
      ForceEnd (override)RunningFinishedUnofficial
      FinalizeUnofficialFinalizedFinal
      AdvanceFinalAdvancedFinal (terminal)
      RevertFinalRevertedUnofficial
      AbortStaged / Armed / RunningAbortedScheduled
      RestartArmed / Running / UnofficialRestartedScheduled
      DiscardFinalDiscardedScheduled
      +

      + Every (state, command) pair not in this table is rejected as an + IllegalTransition. One command carries an additional guard beyond the table: + Finalize is rejected while a protest is open on the heat — + backend and UI (release hardening, #330) — so a result cannot be locked with an unresolved + protest. The two middle forward steps — + Armed → Running and Running → Unofficial — have no everyday + command: they are appended by the runtime clock (§4). The override commands + SkipCountdown and ForceEnd record exactly the same transitions + the auto-path would, as race-day escape hatches. +

      + +

      Abort and Restart both reset to Scheduled

      +
      +

      Every off-ramp lands at Scheduled, so the RD + re-Stages. Abort (from Staged/Armed/Running) + and Restart (from Armed/Running/Unofficial) + both reset the heat fully to Scheduled — there is no "abort back to + Staged" half-step. This makes the off-ramp uniform and unambiguous: whatever went wrong, + the heat returns to a clean, un-run state and the RD re-Stages it. Discard + (re-run a finalized heat) lands at Scheduled the same way; Revert + is the one exception — it re-opens Final → Unofficial for correction without + throwing the result away.

      +
      + +

      3. The transitions in the log

      +

      + Each phase change is an appended HeatStateChanged { heat, transition }. The + HeatTransition vocabulary (the events crate) names the state entered or the + off-ramp taken: +

      +
      +
      Staged · Armed · Running · Finished · Finalized · Advanced
      +
      The forward path. Finished is the transition that lands a heat in + Unofficial (the state and the transition are deliberately named differently — + the race finished, the result is Unofficial).
      +
      Aborted · Restarted · Discarded
      +
      The reset off-ramps — all land at Scheduled.
      +
      Reverted
      +
      Re-opens a finalized result (Final → Unofficial).
      +
      +

      + One more event is part of the lifecycle but is not a HeatStateChanged: when a + heat enters Armed, the start procedure appends a + HeatStarting { delay_ms } fact recording the randomized hold it chose (§4). +

      +

      + A heat's current state is recovered by heat_state(events, heat) — a pure fold + that seeds at Scheduled on the HeatScheduled and walks each + HeatStateChanged through next_state. Folding the same slice twice + yields the same state; an abort-and-re-run reconstructs exactly (back to + Scheduled, then forward again). +

      + +

      4. The runtime clock — the two automatic forward steps

      +

      + The FSM module is pure: it reads no clock and rolls no dice. The two + forward steps that do depend on wall-time and randomness are driven by the + Director's runtime clock (in crates/app/src/source.rs), + which appends the transition itself — the same one an override command would record — so + the pure fold and a replay never have to re-derive it. +

      + +

      4.1 Start procedure — Armed → Running

      +

      + When a heat enters Armed, the start driver reads the round's + StartProcedure and runs the canonical FPV start: announce → a randomized + hold → the go-tone. Today the one procedure mode is RandomizedDelay { + min_delay_ms, max_delay_ms, tone? }, defaulting to a 2000–5000ms + hold. +

      +
      +

      The randomized hold is rolled once, in the runtime, and logged. + The driver picks a delay uniformly in the procedure's window, immediately appends + HeatStarting { delay_ms }, then schedules the Armed → Running + transition for that delay. The randomization happens only at emission time, never in the + fold — so a replay reads the logged delay_ms and reproduces the start exactly + (Race Engine §6). The HeatStarting event also + lets the console cue the start tone (the GridFPV console owns the audible start, not the + timer).

      +
      +

      + If the clock can't be trusted — a stuck countdown, or a race that must go now — the RD's + SkipCountdown override forces Armed → Running immediately, + recording the same Running transition the driver would. +

      + +

      4.2 Completion — Running → Unofficial

      +

      + While a heat is Running, the completion driver polls the round's win condition + against the heat's running passes via the pure predicate + race_end_reached(passes, win_condition, race_start): +

      +
        +
      • Timed — met once a counted crossing lands at or after the window + close (race_start + window_micros): the observable signal that the window has + elapsed on the source clock. Because that crossing may never come (every pilot lands at + the buzzer), the driver also holds a wall-clock fallback — window + + grace, anchored to the first observed pass (race-go if nobody crossed) — so a + timed heat always ends on its own (D19).
      • +
      • FirstToLaps — met once any competitor (the leader) completes + n laps.
      • +
      • BestLap / BestConsecutive (qualifying) — no intrinsic lap/leader + criterion in the passes, so the predicate never fires; such a qual session ends on a time + limit or the RD's ForceEnd.
      • +
      +
      +

      Win condition, then the grace window, then auto-Unofficial. + Once the race-end criterion is met the driver holds the heat in Running for + the round's grace window — so a trailing pilot's final lap still counts — + and only then appends Running → Unofficial. The ForceEnd override + forces the same step now when the completion clock must be bypassed.

      +
      + +
      +

      Clock robustness: per-heat, never replayed, re-checked at fire time. + Drivers are tracked per heat (protest/grace windows legitimately overlap + across heats); the bridge starts from the log tail — a Director restart + replays no history, and a heat caught mid-flight (Armed/Running/ + Unofficial) simply gets fresh clocks over its current state; and every driver + re-checks the heat's folded state at the instant it appends, under the + command serialization lock — so a manual command landing just before a clock expires always + wins (D21, D22).

      +
      + +

      4.3 The grace window

      +

      + The grace window is per-round config: GraceWindow is either + UntilScored (late crossings count for the whole Unofficial phase, + until Final) or Duration { micros } (count only for a bounded + span after the heat finished). The default a round stores is a bounded + 30 seconds — deliberately a Duration, not + UntilScored, because the completion clock must eventually fire the + auto-transition, so the window has to close on its own. The consumes_pass + function decides whether a given pass still counts: always while Running, + within the grace window while Unofficial, never once Final. +

      + +

      5. Per-round configuration

      +

      + The lifecycle knobs live on the round (RoundDef), so each round can tune its + own staging, start, and grace behaviour: +

      +
      +
      staging_timer_secs — default 300 (5:00)
      +
      The staging countdown, informational only (no auto-advance out of + Staged); the console renders it as a countdown.
      +
      start_procedure — default randomized 2000–5000ms
      +
      How the heat auto-advances Armed → Running, with an optional start-tone + cue config for the console.
      +
      grace_window — default Duration of 30s
      +
      How long the runtime holds Running after the win condition is met before + appending Running → Unofficial.
      +
      time_limit_secs — optional (open practice)
      +
      When set, the runtime auto-ends the heat (Running → Unofficial) once its + elapsed running time reaches the limit, independent of any win condition.
      +
      + +

      6. Open practice — a lifecycle with un-logged laps

      +

      + Open practice is the casual format: a round is one open heat over the active + channels (node-{i} seats, not pilots), seeded by + AllChannels { channels }. Its lifecycle is the same six-phase FSM, with two + deliberate differences: +

      +
      +

      The session is logged; the laps are not. The heat's + HeatScheduled and its start/stop HeatStateChanged transitions + are recorded — so the durable log carries the session boundaries — but the + practice passes are in-memory only, keyed per channel, never appended to + the log. The source bridge routes an open-practice heat's passes into a live accumulator + instead of the event log; the served live state takes its phase/clock from the real log and + splices the non-logged per-channel laps over it (so phase can never drift from the log).

      +
      +

      + Open practice does no scoring — there is no win condition (the round + stores an inert one that is never consulted). The heat is auto-created on + round creation, and ends either when its optional time_limit_secs elapses (the + runtime auto-advances Running → Unofficial) or when the RD presses + ForceEnd. +

      + +

      7. Determinism & replay

      +
      +

      Everything non-deterministic lands in the log as a fact. The + FSM fold, the scorer, and the completion predicate are all pure functions of the log. The + only non-determinism — the start procedure's randomized hold — is rolled once in the + runtime and recorded as HeatStarting { delay_ms }; the completion step is a + pure predicate over logged passes. So a recorded session reaches Armed → Running + and Running → Unofficial at exactly the same points on replay, and rebuilds the + same heat states, results, and standings (the testing backbone).

      +
      + + +
      + + + + diff --git a/docs/index.html b/docs/index.html index a8b7429..53af3af 100644 --- a/docs/index.html +++ b/docs/index.html @@ -30,6 +30,11 @@

      Internal Design & Developer Docs

      self-hosters) is a separate concern and will live in its own repository later. Keep that material out of this repo.

      +

      + Release state (2026-06-30): this is a primitives-first release — + three round types (Practice / Time Trials / Head-to-Head) ship; the tournament structures are + carved to the tournaments-snapshot branch (PR #327) for a post-v1 rebuild. +

      @@ -87,7 +92,7 @@

      Pipeline deep dives

      The competition proper — seed, generate the bracket, run and advance; win conditions, - live marshaling, multi-class scheduling. + live marshaling, multi-class scheduling. (Deferred — tournaments-snapshot.)
    • @@ -114,6 +119,15 @@

      Reference

      one-protocol/two-transports contract. The deep technical record — start here.
    • +
    • + +
      + The running record of settled design decisions and their rationale (ADR-style) — + the heat lifecycle and phase naming, the Abort/Restart off-ramps, ephemeral + open-practice laps, the qualifying metric, grace defaults, and more. Why the + system is the way it is. +
      +
    • @@ -122,14 +136,65 @@

      Reference

      to the engine. The vocabulary the whole log speaks.
    • +
    • + +
      + The required in-process RH integration (D16) and its sliced build plan — the verified + RHAPI 1.3+ surface it taps, the Grid↔plugin gridfpv_* socket.io protocol, + which socket workarounds it retires, the required-with-guided-install onboarding, and the + five slices (dev harness → handshake → live dense RSSI → clean start/stop → #3 recalc). +
      +
    • +
    • + +
      + Channels vs. nodes — node count caps pilots-per-heat while a timer can offer more + channels than nodes; the standard FPV catalog plus custom raw-MHz, per-timer + capability (fixed / flexible), static vs. per-heat assignment, and race-time tuning. +
      +
    • - The heat loop, the pluggable bracket / qualifying generator registry, + The heat loop, the pluggable format generator registry, advancement, and multi-class scheduling — the competition machinery that consumes canonical events.
    • +
    • + +
      + The RD-facing taxonomy — three round types (Practice / Time Trial / Head-to-Head), + win condition vs scoring, and round-robin / brackets as tournament structures over + Head-to-Head (decision D17) (structures deferred post-v1). +
      +
    • +
    • + +
      + The heat state machine in depth — the six phases, command legality, the runtime + start procedure and completion / grace transitions, per-round config, and how it + all replays deterministically from the log. +
      +
    • +
    • + +
      + How raw, fallible timer output becomes a defensible result — the three-layer design + (timer-agnostic lap-level corrections, signal-as-evidence, and the governance layer of + provisional → official, audit, reversibility, roles, and adjudication). The realization + of the Vision's "accountability by design" theme. +
      +
    • +
    • + +
      + The sliced implementation plan for marshaling — the headline finding (the correction model + and deterministic fold already exist), the refinements that shrink the design, the six + slices in recommended order, and the load-bearing risks (RSSI fidelity, throw-out + determinism, the offset convention). +
      +
    • @@ -137,6 +202,14 @@

      Reference

      stream, subscription scoping, auth, and versioning.
    • +
    • + +
      + How the live race state is folded from the log, the server-time-authoritative race + clock (anchored to logged transition timestamps; header and HUD agree, exact frozen + duration), and the open-practice laps-only overlay composed onto the log fold. +
      +
    • @@ -165,6 +238,14 @@

      Reference

      read API, export, and embeddable widgets. The pilot owns their data.
    • +
    • + +
      + The pre-release deep review and live soak: the findings→fixes ledger across + scoring, the runtime clock, marshaling, and the console; the decisions it produced + (D19–D24); and the small set of items deliberately deferred. +
      +
    • diff --git a/docs/live-state-clock.html b/docs/live-state-clock.html new file mode 100644 index 0000000..915a03a --- /dev/null +++ b/docs/live-state-clock.html @@ -0,0 +1,350 @@ + + + + + + GridFPV — Live State & the Race Clock + + + +
      +
      +

      GridFPV

      +

      Live State & the Race Clock

      +

      Internal design doc · how a log frame becomes the live projection, the server-authoritative clock, and the open-practice overlay

      +
      + +

      + The live race-state projection (LiveRaceState) is the + latency-sensitive core every overlay, HUD, and spectator watches — the heat on the timer, + its phase, the lineup, per-pilot lap progress, the running order, and the on-deck heat. This + doc ties together three things earlier docs treat separately: how the live state is a + pure fold of the event log, how its race clock anchors to + server-time log timestamps (not a client wall-clock), and how the + open-practice overlay composes non-logged per-channel laps on top of the + log fold without ever letting phase or clock drift. +

      + +
      +

      + This doc is the as-built deep-dive on the live-state projection and the + race clock. It builds on Architecture §3 (the append-only + spine) and Protocol §1–§3 (what the contract serves and the + change stream). It does not redefine the projections (Race Engine) or the timing vocabulary (Timer Adapters). Where this doc and the + Vision disagree, the Vision wins. The code is the source of + truth: crates/server/src/live_state.rs, + open_practice.rs, snapshot.rs, ws.rs, and the + append choke point in app.rs. +

      +
      + +

      1. The live state is a pure fold of the log

      +

      + The event log is the single source of truth (Architecture + §3); the live state is just one of its projections. The function live_state(events) + scans the log once and produces a LiveRaceState deterministically: given the + same events it always yields the same value, so a recorded session replays + identically and the snapshot is always recomputable. +

      +
      +
      current_heat
      +
      The heat currently on the timer: the heat whose latest HeatScheduled / + HeatStateChanged appears last in the log. A heat that reached the + terminal Final phase is still reported as current until a newer heat + takes the timer — mirroring what an overlay shows between heats ("last heat, now scored"). + (Planned: a manual-selection override so the RD can pin the live view to a chosen heat + regardless of last-scheduled order.)
      +
      phase
      +
      The current heat's folded HeatPhase — the linear status + Scheduled → Staged → Armed → Running → Unofficial → Final. The engine's off-ramp + transitions (revert / abort / restart / discard) resolve back onto one of these, so the live + view stays a simple status. phase is Scheduled (the idle default) + when there is no current heat.
      +
      active_pilots + progress
      +
      The lineup of the current heat (from its most recent HeatScheduled), in + seeding order, and one PilotProgress per active pilot. Progress reuses the + existing marshaling-aware lap projection (lap_list_marshaled) filtered to the + lineup, so it carries each pilot's laps_completed and last_lap_micros + with adjudications already folded. A competitor bound to a pilot by a logged + CompetitorRegistered surfaces its PilotId; an unbound channel stays + a bare CompetitorRef with pilot: None.
      +
      running_order
      +
      The provisional live order: most laps first, ties broken by the earliest last-lap + completion, then by competitor ref for a total deterministic order. This is the same + "most laps, then who banked the last lap first" rule the scorer uses mid-heat, but derived + without a win condition (which is heat/format config, not in the raw log) — so it is + a live order, not the authoritative scored HeatResult.
      +
      on_deck
      +
      The next still-Scheduled heat that is not the current one, in first-scheduled + order — the heat the RD stages next.
      +
      race_started_at / race_ended_at
      +
      The server-authoritative race-go and race-end instants (§2). These are not in the + plain &[Event] fold — they are stamped on the stored log and folded in by a + finishing pass (§2).
      +
      staged_at
      +
      The server-authoritative Scheduled → Staged instant, present only while the + heat is Staged — the anchor for the console's staging countdown (release audit, + 2026-07-04). Before this, each console anchored the countdown to its own mount time, so two + consoles (or a reload) read different windows; anchoring to the server instant against + serverNowMs() makes every console read the SAME window, the standing clock-skew + rule.
      +
      +
      +

      One fold, two callers. The plain + live_state(&[Event]) fold produces everything except the clock. The + snapshot and change-stream paths hold the full stored log (which carries the server + timestamps the bare Event slice lacks), so they finish the live state with + with_heat_timing(live, &stored) — a no-op when there is no current heat, + idempotent, order-independent. Same projection, just one extra finishing fold for the clock.

      +
      + +

      2. The race clock is server-time authoritative

      +

      + The race clock is anchored to server-time log timestamps, never to a client + wall-clock. This is the fix for per-client drift: two clients that mount at different moments, + or a client that reloads mid-race, must read the same elapsed time — so the anchor + cannot live in the browser. +

      +
      +

      The anchor is the transition's recorded_at. Every + log entry can carry a recorded_at — microseconds on the server wall clock, the + moment the log received the entry. A heat's Armed → Running transition is the + real race-go, and its recorded_at is race_started_at; the + Running → Unofficial transition is the race-end, and its recorded_at + is race_ended_at. The client derives elapsed as now − race_started_at + while running, and freezes at race_ended_at − race_started_at once the heat + closes — header and HUD share the one anchor, so the elapsed reads identically regardless of + when the client mounted.

      +
      +

      + The timestamps are stamped at one place: the append choke point, + AppState::append. A runtime/control path appends a HeatStateChanged + with no caller timestamp, so the choke point stamps the server wall clock + (now_micros(), microseconds since the Unix epoch) for any + HeatStateChanged that arrives without one. A caller-supplied timestamp — a replay + feeding back the original instant, or a test pinning a value — always wins, so + replay reproduces the exact same clock. +

      +
      +

      The frozen value is the exact server-side duration. Because both + instants are server recorded_at values from the same clock, + race_ended_at − race_started_at is the precise race duration — a 60s limit reads + 1:00.000, not 1:00.100. A re-run after an abort restamps + race_started_at from the new Running and clears the prior end (the + new run has not finished); the last matching transition wins.

      +
      +

      + heat_timing(stored, heat) folds these two instants out of the stored log by + scanning the heat's Running / Finished transitions, and + with_heat_timing attaches them to the folded live state. Like every projection it + is pure and recomputable: a replayed session that carries the same recorded_at + values folds the same race clock. The microsecond integers are bounded far below + 2^53, so they render to TypeScript as a plain number (matching the + wire), per Protocol §6. +

      +
      +

      The client reads server time, not its own clock. The anchors + (race_started_at, and the Armed start countdown's tone_at) are + server instants, but the RD console routinely runs on a separate device + (a field laptop pointed at the Director) whose wall clock can differ by a second or more. So the + now in now − race_started_at must be the server's now, not the + browser's Date.now() — otherwise a skewed device stops the Armed countdown ~1 s + early and offsets the elapsed clock. The session measures the offset against GET /time + (the Director's epoch-µs clock), round-trip-corrected NTP-style on the timer-poll cadence, and + exposes serverNowMs() = Date.now() + offset. The race clock + (useRaceClock) and the start countdown (useArmingClock) both read + serverNowMs(). The frozen duration is server − server, so it is unaffected + by any skew.

      +
      +
      +

      + Why recorded_at and not Pass.at? A + Pass carries a SourceTime on the timer's clock — perfect + for lap/split intervals (immune to network jitter), but each source has its own + timeline (sim engine time, RH server time, device RTC). The wall-clock race-go is a + Director-side fact, so it is the server's recorded_at on the lifecycle + transition — one timeline every client agrees on — while interval math stays on the source + clock. This is exactly the split the + Timer Adapters doc draws. +

      +
      + +
      +sequenceDiagram
      +  participant RT as Runtime clock / RD
      +  participant CK as append() choke point
      +  participant LOG as Event log (recorded_at)
      +  participant FOLD as live_state + with_heat_timing
      +  participant C as Client (header + HUD)
      +  RT->>CK: HeatStateChanged(Running) — no timestamp
      +  CK->>CK: stamp recorded_at = now_micros()
      +  CK->>LOG: append (server-time race-go)
      +  CK-->>FOLD: notify_waiters()
      +  FOLD->>LOG: read_stored()
      +  FOLD->>FOLD: race_started_at = recorded_at(Running)
      +  FOLD-->>C: LiveRaceState { race_started_at }
      +  Note over C: elapsed = now − race_started_at (shared anchor)
      +  RT->>CK: HeatStateChanged(Finished)
      +  CK->>LOG: append (server-time race-end)
      +  FOLD-->>C: race_ended_at set → clock freezes at the exact duration
      +    
      + +

      3. From a log frame to the served projection

      +

      + The read and realtime paths reuse the same fold helpers, so a snapshot and the stream + can never disagree. The snapshot handler reads the stored log, folds the live state, attaches + the clock, merges the open-practice overlay (§4), and returns the body with the cursor the + stream resumes from. The stream handler walks the log forward, re-folding the scope on each new + offset and emitting a fresh-value envelope whenever the folded body changes. +

      +
      +

      The resume cursor is the log length; the per-stream sequence is its own + counter. The snapshot's cursor is the log length at read + time — the offset of the first event after the snapshot. The client presents + it as from on (re)subscribe to resume exactly where the snapshot ended. The + change-stream sequence, by contrast, is a separate per-stream counter starting at + 1 and incremented once per emitted envelope — one log append can fan out into + several projection changes, or none this scope subscribes to. A client persists both: it + renders by sequence order and resumes by the from log-offset cursor.

      +
      +

      + Resume is bounded by a retained window of 256 log offsets + (RETAINED_WINDOW) behind the current tail. A from older than + tail − 256 cannot be replayed, so the server sends the terminal + StreamMessage::ReSnapshotRequired (a StaleCursor + ProtocolError) and the client re-snapshots — always correct, because projections + are recomputable. A from of 0 (or absent, a fresh subscribe) is never + stale. As built in v0.4 every envelope is a FreshValue carrying the projection's + complete new state; the Change::Delta encodings are wired in the type system but + deferred, so the stream is correct-but-chatty until they land + (Protocol §3, §9.2). +

      +
      +

      The per-offset walk folds the pure log; the overlay is spliced last. + The change-stream engine re-folds the pure log on each offset so every logged change — + including phase transitions and the clock instants — produces an envelope. When an + open-practice heat is active it then folds the current prefix and splices the per-channel laps + on top, emitting that laps-spliced fresh value if it differs (§4). Both the snapshot and stream + anchor the clock with with_heat_timing from the stored log, so the clock basis is + identical everywhere.

      +
      + +

      4. The open-practice overlay composes with the log fold

      +

      + Open practice is casual flying: a round is one open heat over the active channels + (node-{i} seats), and each channel's laps are shown live but + deliberately never logged. The session itself is recorded — the heat's + HeatScheduled plus its start/stop HeatStateChanged are in the log — but + the practice passes are not. So the durable log carries the session boundaries and + zero Pass events for an open-practice heat; the source bridge routes + those passes into an in-memory accumulator (OpenPracticeLive) instead of + AppState::append. +

      +
      +

      Phase & clock from the log; laps from the overlay. The served + live state is the log fold (truthful current_heat, phase, lineup, + on-deck, and the server-time clock) with the in-memory per-channel laps + merged on top. merge_into splices the accumulator's + laps_completed / last_lap_micros onto the matching progress + rows and recomputes the running order — only when the overlay's heat is the log's current + heat (a guard so a stale overlay can never bleed laps onto a different heat). Phase and clock + therefore cannot drift — they are always the log's.

      +
      +
      +

      + Why laps-only, and not a synthetic live state. An earlier design served a + fully synthetic LiveRaceState (its own Running start plus a + shadow transition list) in place of the log fold — and it drifted: a Restart + resetting the heat to Scheduled was never reflected (the console kept rendering + Unofficial/Final buttons, and Restart then errored), and + recomputing the synthetic slice reset the clock basis (a ~0.104 s clock bump after the time + limit fired). Making the overlay laps-only over a log-authoritative base removes both + failure modes. +

      +
      +

      + The laps come from the same lap fold — no second lap definition. The accumulator + synthesizes an event slice (the heat's HeatScheduled over the channels, a + Running base, then the accumulated lap-gate passes) and runs it through + live_state; only the per-channel progress is read off that slice, and a + channel is just an unbound competitor so its pilot is naturally None. + The slice's own phase is ignored — the served phase is always the log's. +

      +
      +

      Every mutation and every clear wakes /stream. The + non-logged laps never reach the log's append-notify, so the bridge calls the same + append-notify (wake_streams / the accumulator's record/begin/clear) a real append + would. A parked stream re-folds and pushes a fresh-value envelope on each lap; when the + accumulator clears the re-fold is overlay-free and the console settles immediately back onto the + bare log state — no stale frame.

      +
      +

      + The accumulator's lifecycle is tied to the heat loop: it begins when an + open-practice heat goes Running (over the heat's node-{i} channels, + clearing any prior practice), keeps accumulating through Unofficial + (so laps stay visible after the time limit fires), and is cleared on a terminal / + abort / restart transition or when a new heat or round takes over. Across a Restart + the heat returns to Scheduled in the log and the bridge clears the accumulator, so + the merge of an empty accumulator onto the Scheduled base is the bare log state — + no stale Unofficial, laps cleared. +

      + +
      +flowchart TB
      +  subgraph LOG["Event log (durable)"]
      +    sched["HeatScheduled (open-practice heat)"]
      +    trans["HeatStateChanged<br/>Running · Finished · Restarted<br/>(recorded_at = clock anchor)"]
      +  end
      +  acc[("OpenPracticeLive<br/>in-memory per-channel passes<br/>NEVER logged")]
      +  base["live_state + with_heat_timing<br/>phase · clock · lineup (from LOG)"]
      +  merged["merge_into<br/>splice per-channel laps + running order"]
      +  stream["/stream fresh-value envelope"]
      +
      +  sched --> base
      +  trans --> base
      +  base --> merged
      +  acc -->|laps only| merged
      +  acc -.->|every record / clear| wake["wake_streams (notify)"]
      +  trans -.->|append-notify| wake
      +  wake --> stream
      +  merged --> stream
      +
      +  classDef sot fill:#eaf7e1,stroke:#43b301,stroke-width:2px;
      +  class trans sot;
      +  style acc stroke-dasharray: 6 4;
      +    
      + +
      +

      IRLThe race clock anchors to the staged-and-armed lifecycle; the + open-practice overlay is a sim-flavored, casual mode, but the same log-authoritative phase/clock + apply to a hardware practice heat over RotorHazard channels.

      +

      SimOpen practice is the common case — channels are sim seats, + passes accumulate in memory, and the session boundaries (not the laps) are all the durable log + keeps.

      +
      + +

      5. Putting it together

      +

      + A frame's journey, end to end: a runtime/control input appends a HeatStateChanged; + the append choke point stamps its server-time recorded_at and wakes every parked + stream. The fold reads the stored log, derives the live state purely, anchors the race clock to + the transition instants, and (for an active open-practice heat) splices the in-memory per-channel + laps on top. The snapshot serves that body with the log-length cursor; the stream serves the same + body as fresh-value envelopes keyed by a per-stream sequence. The whole thing is replay-deterministic + because every input — the RD commands, the runtime clock — is a logged event with + a reproducible timestamp, so a recorded session folds the exact same live state and the exact same + clock. +

      + + +
      + + + + diff --git a/docs/marshaling-plan.html b/docs/marshaling-plan.html new file mode 100644 index 0000000..be9af71 --- /dev/null +++ b/docs/marshaling-plan.html @@ -0,0 +1,165 @@ + + + + + + GridFPV — Marshaling Build Plan + + + +
      +
      +

      GridFPV

      +

      Marshaling Build Plan

      +

      Internal design doc · the sliced implementation plan for marshaling

      +
      + +

      + The Marshaling doc says what we're building and + why; this doc says how and in what order. It is the sliced build plan — + six vertical slices in recommended sequence — grounded in the headline finding that most of the + machinery already exists, plus the refinements that shrink the work and the risks that could + undermine it. When this doc and the Marshaling doc disagree, that + doc (and the Vision above it) wins. +

      + +
      +

      + Read alongside Marshaling (the three-layer design), the + correction primitives in Architecture §3, the provisional + Unofficial → locked Final states in + Heat Lifecycle, the control/read auth split in + Protocol §5, and the canonical passes / signal context from + Timer Adapters. +

      +
      + +

      1. Headline finding — the hard part is already built

      +
      +

      The correction data model and the deterministic fold already exist. + The append-only spine already carries the correction primitives and the + scoring fold that consumes them. The corrected-passes path + (corrected_passes) already does void a detection, + insert a lap, and adjust a lap — and it already handles + "void the void" (a correction can itself be superseded, because corrections are + appended facts, never destructive edits). The scoring fold already folds penalties + and a heat-void into the result. So the foundation marshaling needs — immutable + raw observations, appended corrections, deterministic recompute — is not new work. The build is + mostly surfacing and governing what the engine already computes.

      +
      + +

      2. Refinements that shrink the design

      +
      +
      No per-person provenance
      +
      The original design imagined who-changed-what attribution per correction. In practice there is + a single RD actor at the console — corrections are RD acts, full stop. So we do + not carry a per-correction person field; the audit derives from the event + type (the kind of fact appended is the record of what happened). This removes an + identity/attribution sub-system from the v1 scope without losing the audit trail — the log is + still the complete, ordered record of every change.
      +
      Provisional → official reuses the heat FSM
      +
      The lifecycle is not new states: it reuses the heat FSM's + UnofficialFinal transition (Heat + Lifecycle). The protest window is an optional, OFF-by-default + auto-official timer — when set, it fires the Finalize the RD would have + pressed; when unset (the default), nothing auto-finalizes and the RD commits explicitly. No new + lifecycle, just a timer that can drive the existing transition.
      +
      RD-only changes; pilots read-only
      +
      All marshaling mutation is RD-only, enforced by the control/read split + (Protocol §5). The read-only pilot tier sees the + record and the corrections but cannot change anything — a real boundary, not a UI + courtesy. This is consistent with the single-actor model above: one writer, many readers.
      +
      + +

      3. The six slices, in recommended order

      +

      + Each slice is a vertical cut (backend → data → UI where applicable), shippable + before the next. The order front-loads the lap-level capability that works for any timer, + then layers signal evidence, then the governance lifecycle, then full adjudication. +

      +
      +
      Slice 2 — Correction primitives DONE
      +
      The lap-level correction facts: split (one over-long lap into two), + edit-time (adjust a lap's time with the neighbour auto-adjusting), and the + reversible DQ (DQ as a toggle, not a destructive op). Built on the existing + void/insert/adjust + "void the void" primitives. Done — this is the foundation the rest + surfaces.
      +
      Slice 3 — Lap-level Marshaling UI + audit IN PROGRESS
      +
      The marshal-facing surface for the Slice 2 primitives: add / remove / split / edit-time / + reversible-DQ against the lap list, plus the audit view (the ordered list of + corrections, derived from the event types per §2). Timer-agnostic — works without any signal + trace. In progress.
      +
      Slice 1 — RSSI-trace capture
      +
      The one genuinely new plumbing (Marshaling §4): the + RotorHazard adapter additionally captures and persists the + per-node RSSI trace plus enter/exit thresholds for the heat as + immutable facts. No UI yet — this lands the evidence in the log so the next slice + can draw it.
      +
      Slice 4 — Signal-as-evidence graph
      +
      Render the captured RSSI trace as a graph with lap markers and thresholds overlaid, so the + marshal sees why the timer called (or missed) a lap and corrects against the visual. + Signal-as-evidence only — we display the trace, we do not + re-run detection (the RotorHazard-style "Recalculate" with draggable thresholds stays deferred, + a clean add-on on the stored trace). Sim heats have no trace and skip the graph.
      +
      Slice 5 — Provisional → official lifecycle
      +
      Surface the UnofficialFinal commit as the explicit + software lifecycle, with the optional, OFF-by-default protest-window timer that + can auto-finalize (§2). RD commits; Revert re-opens; pilots read-only. + Realized note (2026-06-30): Finalize is now gated on open protests — + rejected, backend and UI, while a protest is open (#330).
      +
      Slice 6 — Full adjudication
      +
      The real adjudication framework: first-class DQ states, penalty + time and points, lap throw-outs, and protests — + richer than RotorHazard's +time/note model. The deepest slice, built last on top of + everything above. + Realized note (2026-06-30): adjudications (DQ / TimeAdded / lap edits / throw-out / void) + now reach round ranking, standings, and seeding via the shared score_heat_window + scorer (#329, closing #226), and DQ is excluded from ranking + (#331).
      +
      + +
      +flowchart LR
      +  S2["Slice 2
      Correction primitives
      (DONE)"] + S3["Slice 3
      Lap-level UI + audit
      (IN PROGRESS)"] + S1["Slice 1
      RSSI-trace capture"] + S4["Slice 4
      Signal-as-evidence graph"] + S5["Slice 5
      Provisional → official"] + S6["Slice 6
      Full adjudication"] + S2 --> S3 --> S1 --> S4 --> S5 --> S6 +
      + +

      4. Key risks

      +
      +
      RSSI fidelity (open question)
      +
      The load-bearing risk for the evidence layer (Slice 1/4). RotorHazard's own worst + marshaling bug was the recorded trace diverging from the realtime signal. Before + we rely on it: does RH expose a continuous RSSI history, or only the latest sample? + This must be verified on hardware — if only the latest sample is exposed, the + "trace" we capture is sparse and the evidence layer's value drops accordingly. Verify before + committing the graph UI.
      +
      Throw-out determinism
      +
      Lap throw-outs (Slice 6) must fold deterministically — the same log must + always yield the same thrown-out set, regardless of correction order. Any throw-out rule that + depends on evaluation order (rather than being a pure function of the folded laps) breaks replay.
      +
      Offset convention: window-relative vs. global
      +
      Corrections reference times; the convention for those times — relative to the heat's + start window vs. an absolute/global timestamp — must be fixed and + consistent across capture, correction, and display, or edits land at the wrong instant. Pick one + convention and hold it through every slice.
      +
      + + +
      + + + + diff --git a/docs/marshaling.html b/docs/marshaling.html new file mode 100644 index 0000000..27c35a6 --- /dev/null +++ b/docs/marshaling.html @@ -0,0 +1,333 @@ + + + + + + GridFPV — Marshaling + + + +
      +
      +

      GridFPV

      +

      Marshaling

      +

      Internal design doc · how raw timer output becomes a defensible result

      +
      + +

      + Marshaling is the human-in-the-loop review that turns a timer's raw, fallible output into + results a chapter can stand behind. This doc records the research behind the direction and + the decided plan: a three-layer design — timer-agnostic lap-level corrections, signal-as-evidence + where a trace exists, and the governance layer (provisional → official, audit, reversibility, + roles, adjudication) that no existing system has. It is the concrete realization of the + Vision's "defensible results — accountability by design" theme. + The sequenced build — slices, refinements, and risks — lives in the + Marshaling Build Plan. +

      + +
      +

      + This doc builds directly on the append-only spine and the corrections/marshaling semantics + in Architecture §3, the provisional Unofficial → + locked Final states in Heat Lifecycle, the + control/read auth split in Protocol §5, and the canonical passes + from Timer Adapters. When this doc and the + Vision disagree, the Vision wins. +

      +
      + +

      1. Why marshaling exists

      +

      + RF/RSSI timers are statistical, not exact. A node decides a pilot passed the + gate when the received signal crosses an enter/exit threshold — and that signal is noisy. The + two failure modes are unavoidable in the physics: +

      +
        +
      • Missed laps — a real pass whose signal never cleared the threshold (low + power, bad antenna angle, a pilot far from the node) is simply not recorded.
      • +
      • False triggers — interference, a neighbouring pilot's video, or a + reflection crosses the threshold and fabricates a lap that never happened.
      • +
      +
      +

      Raw timer output is not trustworthy as results. The unreviewed + stream of passes is a draft, not a verdict. Every serious timing system therefore + puts a human between the detector and the official result — the marshal — whose job is to + reconcile what the timer reported against what actually happened. GridFPV's differentiator is + not that it marshals, but how: every reconciliation is a recorded, attributable, + reversible fact rather than a silent overwrite.

      +
      +
      +

      IRLMarshaling is heavy: recover missed laps from signal history, + re-time enter/exit points, kill phantom laps from interference. The signal is the evidence.

      +

      SimLight: a Velocidrone heat's detection is exact, so marshaling + is mostly abort / restart and discard-and-re-run. There is no signal trace to review.

      +
      + +

      2. Research synthesis

      +

      + We surveyed how the established systems handle marshaling. The short version: one nails the + signal review, one nails the lap model, one is a results backbone + and not a marshaling tool at all — and none of them encodes governance. +

      + +

      2.1 RotorHazard — the signal-based gold standard

      +

      + RotorHazard is the reference for + signal-grounded marshaling. Per node it persists the RSSI trace + together with the enter/exit thresholds, and per lap it keeps the peak RSSI, the detection + source, and a soft-delete flag. The marshal reviews the RSSI graph for a node + and add / deletes / adjusts laps against the visible signal; the "Recalculate" + action re-runs the pass-detection algorithm over the stored signal (optionally with adjusted + thresholds) to re-derive laps. Because the raw trace is retained, the review is evidence-based + rather than guesswork. +

      +
      +

      RotorHazard's weak spot: no adjudication, no audit, no roles. + Penalties are modelled only as +time or a free-text note — there is no first-class + DQ / penalty-points / lap-throwout framework. There is no audit trail of who + changed what when, and no role model — anyone with the page can edit. The + signal review is excellent; the governance around the result is absent.

      +
      + +

      2.2 FPVTrackside — the richest lap-level model

      +

      + FPVTrackside has the most refined + lap-level model. A lap is two detections — an enter and an + exit — and the marshal can: +

      +
        +
      • Add / remove a detection;
      • +
      • Split one over-long lap into two (a missed mid-lap detection);
      • +
      • Edit a lap's time, with the neighbouring lap auto-adjusting + so total race time stays consistent;
      • +
      • DQ a pilot reversibly — disqualification is a toggle, not a destructive op.
      • +
      +

      + It also uses a deferred "smart-minimum": rather than immediately invalidating a + too-fast lap, it waits for the next detection before deciding, so a borderline double-trigger + isn't thrown out prematurely. Calls are grounded in video replay — the marshal + watches the footage to adjudicate. +

      + +

      2.3 MultiGP RaceSync — a results backbone, not a marshaling tool

      +

      + MultiGP RaceSync is the results backbone + for US chapter racing (and our push target), but it is not a + marshaling surface. Its provisional → official notion is a rulebook convention + ("results become official 10 minutes after the race unless the RD opens a review"), enforced by + humans, not by software. And its results-lock-on-save is a destructive + footgun: saving can overwrite without an undo path. It tells us what the backbone + needs, and warns us what not to do at the lock boundary. +

      + +

      2.4 The cross-system white-space

      +
      +

      No system has roles or an audit trail; almost none encode a + provisional → official state in software. RotorHazard has the best signal + review but no governance; FPVTrackside has the best lap model but no audit/roles; RaceSync has + the backbone but treats provisional→official as a human convention. The gap none of them fills — + who-changed-what-when, role-gated commits, an explicit software lifecycle, and full + reversibility — is exactly GridFPV's differentiation, and it falls out of the + append-only architecture almost for free.

      +
      + +

      3. GridFPV's decided direction — three layers

      +

      + The design stacks in three layers. Each is independently valuable; together they are the + "defensible results" theme made real. +

      + +
      +flowchart TB
      +  L1["Layer 1 — Foundation
      timer-agnostic lap-level marshaling"] + L2["Layer 2 — Evidence
      signal-as-evidence (RotorHazard first)"] + L3["Layer 3 — Differentiator
      the governance layer"] + L1 --> L2 --> L3 + log[("Append-only log")] -.->|every action is a logged fact| L1 + log -.-> L2 + log -.-> L3 +
      + +

      3.1 Foundation — timer-agnostic lap-level marshaling

      +

      + FPVTrackside-grade lap editing, but for any timer. Laps are enter/exit detections; the + marshal can add, remove, split, edit-time (with the neighbour auto-adjusting), and reversibly DQ, + with the deferred smart-minimum so borderline laps aren't invalidated prematurely. This is built + on the logged correction primitives that already exist in + Architecture §3 — DetectionVoided, + LapInserted, LapAdjusted, PenaltyApplied — extended with + new split, edit-time, and reversible-DQ facts. +

      +
      +

      Every correction is a logged fact with provenance — for free. + Because corrections are appended events that reference the raw observations they adjudicate (never + destructive edits), who / when / source ride along on every one without extra plumbing. This layer + is timer-agnostic: it works for RotorHazard, LapRF, Velocidrone, or manual entry, because it + operates on canonical passes, not hardware specifics.

      +
      +
      +

      The marshaling surface is scoped to the heat under review, independent of + Race Control. A heat picker selects which heat to marshal — it defaults + to and follows Race Control's current heat but can pin any heat, and switching it issues + no SetCurrentHeat, so the marshal can review a finished heat while + the next one stages. A pilot picker narrows the signal graph + lap list to one + pilot. The lifecycle badge and the result transitions read the marshaled heat's own + heat-scoped live fold, not the global current heat. The result-lifecycle transitions + (Finalize Unofficial→Final, Revert Final→Unofficial, and + Void) live here too, so the RD can call a result without leaving marshaling.

      +
      + +

      3.2 Evidence — signal-as-evidence where the timer provides a trace

      +

      + Where a timer exposes a signal trace — RotorHazard first — we capture and persist + the per-node RSSI trace plus its enter/exit thresholds for the heat as + immutable facts in the log. The Marshaling UI then renders the RSSI graph with the + lap markers and thresholds overlaid, and corrections stay lap-level against the visual: + the marshal sees why the timer called (or missed) a lap and adjusts accordingly. +

      +
      +

      Decision: v1 is signal-AS-EVIDENCE, not a re-implemented detector. + We persist and display the trace as the ground truth to review against, but we do + not re-run detection in v1. The full RotorHazard-style "Recalculate" with + draggable thresholds (re-deriving laps from the stored signal) is explicitly deferred + — it is a clean later add-on on top of the stored trace, not a v1 requirement. Building evidence + first, re-detection later, keeps the differentiating governance layer from waiting on a signal + algorithm we don't yet need.

      +
      +
      +

      IRLRotorHazard heats carry an RSSI trace; the graph is the evidence + surface and corrections are made against it.

      +

      SimVelocidrone heats are deterministic with exact detection — there + is no trace, so the Marshaling UI skips the graph entirely and falls back to the + lap-level layer alone.

      +
      + +

      3.3 Differentiator — the governance layer nobody has

      +

      + This is the layer that sets GridFPV apart, and it is mostly architecture we already have, named + and surfaced: +

      +
      +
      Provisional → official lifecycle
      +
      Results move through an explicit, software-enforced state with a configurable protest + window — not a rulebook convention. This builds on the heat FSM's provisional + Unofficial → locked Final transition (Heat + Lifecycle), which already has Revert to re-open a locked result.
      +
      Full audit trail
      +
      Who changed what, when — recoverable for every action, because every action is a logged event. + Nothing is silently lost.
      +
      Everything reversible
      +
      No destructive operations. The raw observation layer is immutable; corrections are appended and + can themselves be superseded (void the void). This is the opposite of RaceSync's lock-on-save + footgun.
      +
      Role-gated
      +
      The RD commits; the read-only pilot tier sees the record but cannot change it. Enforced by the + control/read split in Protocol §5 — a real boundary, not a UI courtesy.
      +
      A real adjudication framework
      +
      First-class DQ states, penalty time and points, lap throw-outs, and protests — richer + than RotorHazard's +time/note model.
      +
      +

      + Realized (2026-06-30): adjudications (DQ / TimeAdded / lap edits / throw-out / + void) now reach round ranking, round standings, class standings, and seeding via the shared + score_heat_window scorer (#329, closing #226); DQ is + excluded from ranking (#331); and Finalize is gated on open + protests — rejected, backend and UI, while a protest is open (#330). +

      +

      + Hardened (2026-07-04, the release audit): a protest dies with the + run it contests — filings before the heat's latest reset (Abort / Restart / + Discard) no longer gate Finalize, since the field re-races anyway (this closed + an RD deadlock where a pre-Restart protest blocked finalization while no run-windowed view + could show it). Rulings apply effectively once: a filing takes one + resolution and a heat takes one void — reverse the standing ruling to re-decide. Every + correction command validates and appends atomically (the command + serialization lock), so a ruling can no longer land on a heat that went Final + in the same instant. See D22/D23 and the + Release Audit. +

      +

      + Deep-dived (2026-07-04, round 2): the marshal's removals and threshold + re-detection now share ONE record — the lap list carries the RD-voided passes, re-detection + never re-proposes them, and a Restore button undoes a mistaken removal + (void-the-void, now a sanctioned command). Rulings are run-scoped (targets + from abandoned runs are rejected; protests and heat-voids need a current run) and + effectively-once (throw-outs and DQs join VoidHeat/ResolveProtest), and + inserted/re-timed instants are validated (positive, bounded, no same-instant collisions — + a zero-duration lap can no longer be created). D25 and the + Release Audit §4 carry the full story. +

      +

      + Min-lap floor (D26): a raw pass that + would close a lap under the round's minimum lap time is auto-removed by the same + fold that applies marshal voids — shown on the lap list as "under min lap, auto-removed" + with a Restore override (Restore is an explicit re-time ruling, which always outranks the + floor). Timers are dumb emitters; GridFPV owns lap semantics. +

      + +

      4. Architectural fit

      +

      + Most of this design is not new machinery — it is what the + append-only log + deterministic replay already give us, made + visible. Audit, provenance, reversibility, and recompute-after-a-ruling are native: + corrections are facts that fold into the projection, so the result recomputes deterministically after + any change, and the full history is always intact. The auth tiers + (Protocol §5) supply the role boundary. The lifecycle states + (Heat Lifecycle) supply provisional → official. +

      +
      +

      The one genuinely new plumbing: capturing and storing the raw RSSI trace. + The RotorHazard adapter today ingests passes, not the + underlying signal history. Layer 2 requires the adapter to additionally capture and persist the + per-node RSSI trace and thresholds as immutable facts. Everything else reuses existing primitives.

      +
      +
      +

      + Fidelity / quantization caution. RotorHazard's own worst marshaling bug was its + recorded signal history diverging from the realtime signal — quantization and + sampling differences meant the trace a marshal reviewed didn't match what the detector actually + saw live, so "Recalculate" could disagree with the original call. If we capture the trace, we must + capture it at a fidelity that matches realtime detection, or the evidence layer undermines the very + trust it exists to provide. This is the load-bearing risk in Layer 2. +

      +
      + +

      5. Deferred / future seams

      +
        +
      • Full signal re-detect. The RotorHazard-style "Recalculate" with draggable + thresholds, re-deriving laps from the stored trace. The v1 evidence layer stores the trace precisely + so this is a clean later add-on.
      • +
      • Video / DVR evidence linkage. FPVTrackside grounds calls in video; we want the + same. Designed as a timestamp-flag seam now — marshaling facts carry timestamps that + a future DVR layer can pivot on — without building the capture pipeline yet.
      • +
      • Richer adjudication. Deeper protest workflows, multi-step rulings, and + penalty-points models beyond the v1 framework.
      • +
      + +

      6. Open product questions

      +
        +
      1. Adjudication depth for v1. How much of the DQ / penalty-points / + lap-throwout / protest framework ships in the first cut versus follows later.
      2. +
      3. Protest-window default. The default configurable window length before a + provisional result becomes official, and whether it varies by phase (qualifying vs. mains).
      4. +
      5. Exact role model at Finalize. Precisely which roles may commit, revert, or + protest at the Unofficial → Final boundary, and how that maps onto the loopback-RD / + remote-passphrase / read-only-pilot tiers.
      6. +
      + + +
      + + + + diff --git a/docs/mock-race-day.md b/docs/mock-race-day.md new file mode 100644 index 0000000..c444dc1 --- /dev/null +++ b/docs/mock-race-day.md @@ -0,0 +1,91 @@ +# Mock race days — drive the app, let the test plugin emulate the races + +A hands-on harness for exercising GridFPV end-to-end without hardware: **you** drive the +Director (build an event, seat pilots, Stage → Start heats, marshal, advance), and the +`gridfpv_mock` **test plugin** emulates each race over the wire — realistic per-pilot laps +and live RSSI traces — reacting to the heats you start. + +It's built on the RotorHazard plugin (decision D16): the **`gridfpv`** plugin streams live +signal + per-node passes; the **`gridfpv_mock`** plugin (test-only) lets the autopilot +inject emulated passes. See [`rotorhazard-plugin.html`](rotorhazard-plugin.html). + +## What's running + +After the overnight deploy, three things are up: + +| Service | Where | What | +|---------|-------|------| +| **Director** | http://127.0.0.1:8080 | The GridFPV app (latest `devel` build), data in `~/gridfpv-data` | +| **Race RotorHazard** | http://localhost:5055 | RH v4.4.0 with the `gridfpv` + `gridfpv_mock` plugins (container `gridfpv-race-rh`) | +| (untouched) | :5099 | The maintainer's `gridfpv-demo-rh` — left alone | + +The active event's timer already points at `http://localhost:5055` and reads **Connected** +with a green **plugin ✓** chip. + +> If the Director or race RH aren't running, restart them — see "Restarting" at the bottom. + +## Run a race day + +1. **Start the autopilot** for a scenario: + + ```sh + cargo xtask race-day clean # or: varied | messy | pack (cargo xtask race-day list) + ``` + + It attaches to the race RH and prints `race-day autopilot ready […]`. Leave it + running; it watches for heats you start. Ctrl-C ends it. + +2. **In the Director** (http://127.0.0.1:8080): build an event (pilots, classes, heats), + seat pilots on nodes, then **Stage → Start** a heat. + +3. **Watch it race.** The autopilot detects RACING and emulates the seated nodes per the + scenario — laps appear, the leaderboard updates, and the marshaling trace shows the + live RSSI bells. When the scenario's laps are in, finish the heat and advance. + +4. **Run the next heat** — every heat you Start gets freshly emulated. Switch scenarios by + Ctrl-C'ing the autopilot and starting it with a different one. + +## The scenarios + +| Scenario | What it exercises | +|----------|-------------------| +| `clean` | Smooth, steady-pace laps, strong signal — the happy path | +| `varied` | Per-pilot pace spread + lap-to-lap jitter — a realistic leaderboard | +| `messy` | **Marshaling practice**: a missed lap, a false/extra pass, and a DNF | +| `pack` | A tight field crossing close together — close finishes | + +`messy` is the one for practicing the marshaling tools (void a false pass, add a missed +lap, handle a DNF). + +## Notes / gotchas + +- The autopilot emulates whichever nodes are **tuned** (the Director tunes the seats it + uses); passes for any other default-tuned nodes are ignored by the Director's lineup, so + they're harmless. +- It injects laps through RH's genuine pass pipeline (`intf_simulate_lap`) and appends real + RSSI history, so both the lap list **and** the live-signal/marshaling trace are populated. +- This is **test-only**: `gridfpv_mock` reaches into RH internals and is never shipped to + users (it's not in the downloadable plugin bundle). + +## Restarting + +Race RH (both plugins, no signal CSV — the autopilot is the only source): + +```sh +docker rm -f gridfpv-race-rh +docker run -d --name gridfpv-race-rh -p 5055:5000 \ + -v "$PWD/plugins/gridfpv:/opt/RotorHazard/src/server/plugins/gridfpv:ro" \ + -v "$PWD/plugins/gridfpv_mock:/opt/RotorHazard/src/server/plugins/gridfpv_mock:ro" \ + gridfpv-rotorhazard:4.4.0 +``` + +(`cargo xtask race-day` will also start it automatically if it's not running.) + +Director (latest build, with the live RH connector): + +```sh +cd frontend && npm run build && cd .. +cargo build -p gridfpv-app --features live +GRIDFPV_ASSETS="$PWD/frontend/apps/rd-console/dist" GRIDFPV_DATA_DIR=~/gridfpv-data \ + GRIDFPV_ADDR=0.0.0.0:8080 ./target/debug/gridfpv +``` diff --git a/docs/pipeline-mains.html b/docs/pipeline-mains.html index 74fe428..947fb73 100644 --- a/docs/pipeline-mains.html +++ b/docs/pipeline-mains.html @@ -19,6 +19,18 @@

      Phase 4 — Brackets / Mains

      to a winner. This is the circular heart of the pipeline — run, score, advance, run again.

      +
      +

      + Status (2026-06-30, PR #327) — this entire phase is deferred out of the + primitives-first release. The tournament structures (round-robin, single/double + elimination, multi-main, chase-the-ace, the bracket builders and viz) were carved off + devel and preserved on the tournaments-snapshot branch, to be + rebuilt over Head-to-Head rounds on the hardened base post-v1 + (Format Model §4, D17). The + doc below is the recorded design that rebuild starts from. +

      +
      +

      This is a deep dive on one phase of the @@ -38,22 +50,62 @@

      Role

      Capabilities

      +
      +

      + As built (v0.4) — the Rounds form. A bracket round is created on the + Rounds+Heats stage-page: a Label, a Format (friendly name — + Single/Double Elimination, Multi-Main, Round Robin), + then dynamic per-format fields — a single-select class (a round targets one + class), a win condition, a seeding rule (From roster + or From ranking with a source round + top-N — the cut is now labeled + “Take top” and bounded by the source rounds' real field, PR #332), a channel mode + (Static / Per-heat), the format's declared params, and a Start & Timing block + (staging timer default 5:00, randomized start delay entered in + seconds default 2–5 s, grace window default 30 s). + Selections auto-save where the page auto-saves; the round form itself is an explicit + add/save. +

      +

      + Planned — rounds are event-level, class-tagged, and dynamic. In the + race-running redesign, rounds live on the event (not buried inside one + bracket structure) and each round is tagged with the class it is eligible for. + Rounds are added dynamically as the event goes: practice and qualifying rounds are + appended as-you-go, and advancing to brackets generates the full bracket from the + qualifying ranking (top-N) as a set of rounds — then editable by the RD (the #84 + Class→Rounds carry). Decided (not yet built): a bracket generates as + one round per level (Quarters/Semis/Final), seeded round-to-round via + FromRanking — single-elim is a clean winners-chain, double-elim's cross-bracket + losers-of feed is the design problem (D13, #217). Heat-filling has + two v1 modes: manual and format-generated (the generators); + a “Generate heats” action fills the whole deterministic round in + one pass (backend loops FillRound; D12, #216). + ZippyQ is shelved (D10) and PWA + self-register is deferred. (Data model in Architecture + §4; engine carry in Race Engine §3/§5; UI is the combined + Rounds+Heats stage-page, Clients §1.) +

      +
      +

      Seed & generate the bracket

      The mains start by seeding the field — from the qualifying ranking, or a manual / imported / registration order — and then generating a bracket: the rounds and heats - pilots will fly. Generation is the entry to the mains, not a separate phase. + pilots will fly. Generation is the entry to the mains, not a separate phase. Concretely, the + “advance to brackets” action takes the quali ranking's top-N and + generates the full bracket as event-level rounds (class-tagged), which the RD + may then edit before they fly (#84).

      • Bracket formats — a pluggable registry of generators: round robin, single elimination, double elimination, multi-tier mains (A/B/C/D/E with top-N advancing up a tier), Chase-the-Ace finals, and MultiGP/FAI P9–P16 style brackets.
      • Leaderboard formats — e.g. the MultiGP Global Qualifier - (GQ): not an elimination bracket but a ranked leaderboard. Each pilot flies a fixed - number of packs (battery runs / attempts; default 10) and is ranked by - their best fastest 3 consecutive laps across those - packs. There is no seeding bracket and no round-to-round advancement — the leaderboard is + (GQ) = Time Trials + Best 3 Consecutive + (D11): not an elimination bracket but a ranked leaderboard. Each + pilot flies a fixed number of packs (battery runs / attempts; default 10) and + is ranked by their best fastest 3 consecutive laps across + those packs. There is no seeding bracket and no round-to-round advancement — the leaderboard is the result. GQ events typically skip qualifying (the GQ is the ranking) and pair with practice.
      • Configuration — pilots per heat (4 / 6 / 8 / 10), how many seeds advance @@ -64,9 +116,11 @@

        Seed & generate the bracket

        Win condition & scoring

          -
        • Configurable per heat/round: first to X laps (default), or a - time limit (most laps wins, with the - last-lap rule and total time as the tiebreak).
        • +
        • Configurable per round via the Win condition dropdown — as built the four + options are Timed — Most Laps (a time window in seconds; most laps wins, with + the last-lap rule and total time as the tiebreak), + First to N laps, Best lap, and Best N + consecutive.
        • Advancement is determined by top-N finishing positions per the generator's rules.
        • Leaderboard formats (e.g. GQ) instead score each pack by the @@ -106,10 +160,22 @@

          Multi-class scheduling

          RD orders and can rearrange, so classes interleave however the RD wants.
        -

        Frequency / channel assignment

        +

        Channel assignment — timer-defined, dynamically tuned

          -
        • (IRL) assign a frequency / channel per heat, avoiding conflicts across the pilots flying - together.
        • +
        • (IRL) per heat, assign each pilot a channel from the channels the selected timer + defines — the standard FPV catalog plus whatever the adapter's capability allows + (fixed allowed-set, or flexible standard + custom raw-MHz; Setup). + The number of pilots in a heat is capped by the timer's node/slot count.
        • +
        • Assignment is engine first-fit + manual — the engine fills conflict-free in + seed order and the RD can override.
        • +
        • At race time GridFPV dynamically tunes the timer's nodes to the assigned + channels: the engine decides the assignment, the adapter applies the tuning to the hardware + (Channel Model §6; Race Engine §5; + Timer Adapters §5).
        • +
        • The number of pilots in a heat is capped by the timer's node count, but the + timer's available channels can exceed that — the pool the engine first-fits + from (Channel Model §2).
        • +
        • Sim has no channel step (the sim timer declares no channel capability).

        Sim vs IRL

        @@ -126,7 +192,9 @@

        Entities & data implied

        Bracket / generator config (per class)
        generator type, pilots-per-heat, seeds-advance, finals format, and — for pack-based leaderboard formats like GQ — the pack count and scoring metric.
        Round → Heat → Heat slot
        -
        a slot is a pilot assignment with a seed rank; heats group into rounds.
        +
        a slot is a pilot assignment with a seed rank (and, IRL, an assigned channel); heats group + into rounds. Rounds are event-level and class-tagged (eligible class[es]), added + dynamically; heats are filled manually or by a format generator.
        Heat win condition
        first-to-X or time-limit, plus its value (lap target or duration).
        Heat result
        @@ -150,8 +218,12 @@

        Open decisions

        within advancement.
      • Finals specifics. Chase-the-Ace ("first to 2 wins") and the grand-final bracket reset in double-elimination.
      • -
      • Frequency assignment. Automated channel assignment versus manual - per heat.
      • +
      • Resolved — Channel assignment & model. Channels are + defined on the selected timer (standard catalog + the adapter's fixed/flexible + capability + custom raw-MHz), not an event-level pool; per heat the engine assigns by + first-fit and the RD may override, capped by the timer's node count, and the + adapter dynamically tunes the nodes at race time + (Setup, Race Engine §5).
      • Seeds-advance vs tiers. How seeds-advance interplays with multi-tier "top-N advance up a tier".
      • diff --git a/docs/pipeline-practice.html b/docs/pipeline-practice.html index 4898f74..0f79cdd 100644 --- a/docs/pipeline-practice.html +++ b/docs/pipeline-practice.html @@ -16,8 +16,9 @@

        Phase 2 — Practice

        Flying time before anything counts. An optional, event-wide warm-up where pilots shake - out gear, get comfortable on their seat and frequency, and (on a real timer) let the - system calibrate to them — without a single result feeding the competition. + out gear, get comfortable on their channel, and (on a real timer) let the system calibrate + to them — without a single result feeding the competition. As built, it is a single + channel-seated Open Practice round.

        @@ -35,87 +36,109 @@

        Role

        Practice sits above the classes — it is open to everyone at the event, not a per-class competition. Its job is to get pilots and equipment ready: warm up, verify seat and frequency, and give the timer a chance to learn each pilot. Its output is - readiness plus an informal hot-lap board and logged practice laps — none of which ever - seed qualifying. + readiness plus an informal hot-lap board — none of which ever seeds qualifying.

        +
        +

        + As built (v0.4) — Practice is the open_practice round. In the + race-running redesign there is no separate “Practice phase” with its own modes. + Practice is just a round on the event whose format is + open_practice (friendly name “Open Practice”), + created from the same Rounds+Heats stage-page as every other round. Selecting that format + swaps the round form's class/win-condition/seeding block for an active-channels + picker and an optional time-limit (minutes) field — nothing else. + The three-mode model below (rolling / scheduled / free-fly) and the shared rolling-queue + engine are not built; what shipped is the channel-seated free-fly case only. + (Engine: round_engine.rs open-practice path; UI: + Clients §1 Rounds+Heats + Live control.) +

        +
        +

        Capabilities

        -

        Organize the field

        -

        Three modes; the race director picks what fits the session:

        -
          -
        • Rolling "fly when ready." Pilots join a queue and the system assigns - the next open slot and frequency/seat as it frees up, managing throughput so the field - keeps flowing. This is the same queue idea qualifying's ZippyQ uses — likely one shared - engine (see Qualifying, coming soon).
        • -
        • Scheduled practice rounds. Fixed practice heats with assigned pilots - and frequencies — a mini-schedule for structured practice windows.
        • -
        • Free-fly (manual). No structure; pilots self-coordinate. The Director - optionally still provides live timing and overlays. In this mode timing is keyed to the - frequency / node, not a specific pilot — anyone can hop on a channel and - get lap times without being bound to an identity. (Pilot binding is optional here; it's - what the structured modes add.)
        • -
        +

        Organize the field — one open heat over the active channels

        +

        + Open Practice is channel-seated, not pilot-seated. The round form's + active-channels picker selects which of the primary timer's node seats are + live; the engine then auto-creates a single open heat whose “lineup” + is those channels (competitor refs are node-0, node-1, … — the seats, + not pilots). Anyone can hop on an active channel and get timed; there is no roster, no binding, + no class. An optional time limit (entered in minutes; blank = no limit, end it + manually) auto-ends the heat when it expires. +

        +
        +

        + Not built (deferred): a rolling “fly when ready” queue, + scheduled practice rounds with assigned pilots, and the shared rolling-queue engine that was + once meant to span practice and ZippyQ. The redesign deliberately ships only the open, + channel-keyed run; ZippyQ-style rolling fills and the PWA self-register queue are post-v0.4. +

        +
        -

        Hot-lap board & logging

        +

        Hot-lap board — per channel, in-memory

          -
        • A live, informal hot-lap board so pilots can gauge themselves — keyed by - pilot in the structured modes, or by frequency / node in free-fly - (optionally tagged by class). Every entry shows the fastest lap; the - recent laps (last ~5) are essential in free-fly — where a node has no - pilot identity, the running laps are how someone recognizes their own flying — and a nice - addition in the structured modes too, so a pilot can read pace lap by lap rather than just - their single best.
        • -
        • Raw laps are logged, but flagged practice and non-binding — practice - never feeds seeding. The board is feedback, not a result.
        • +
        • Live control renders a per-channel practice board in place of the usual + pilot panels: one row per active channel showing the node seat, its resolved + channel label, laps, last lap, and best lap + (best tracked client-side across the run).
        • +
        • Practice laps are held in memory, not appended to the log — they are cleared + when the heat changes (a “New run · clear board” action mints a fresh heat + and resets the board). Practice never feeds seeding; the board is feedback, not a result.

        Warm-up & the heat loop

        • Shake out gear; confirm the right seat and frequency before it counts.
        • -
        • Practice runs the same recurring heat loop as every other phase - (schedule → stage → arm → race → land → score), but scoring is - non-binding (see the event pipeline overview).
        • +
        • The open-practice heat still runs the shared heat lifecycle + (schedule → stage → arm → race → land), but it scores nothing and persists + nothing (see the event pipeline overview).

        Sim vs IRL

        -

        IRLFrequency/seat assignment applies in the rolling and - scheduled modes; practice is the natural timer-calibration window — the - timer learns each pilot's signal so real races read clean.

        -

        SimRuns can fire back-to-back; free-fly is just hot laps on - the track; no frequency management and no calibration.

        +

        IRLThe active channels are the primary timer's node seats; the + adapter tunes those nodes and anyone on a channel gets timed. Practice is still the natural + timer-calibration window (planned), where the timer learns each pilot's signal + so real races read clean.

        +

        SimThe sim timer declares no channels, so the active-channels + picker is empty; free-fly is just hot laps on the track. No frequency management, no calibration.

        Entities & data implied

        What this phase needs to exist (conceptual — not a schema; the Architecture doc formalizes it):

        -
        Practice session (event-wide)
        -
        mode (rolling / scheduled / free-fly) and an enabled flag.
        -
        Practice queue (rolling mode)
        -
        entries of pilot, ready state, assigned slot/frequency, and throughput/rest — the same concept as qualifying's queue.
        -
        Practice run
        -
        a heat-loop run marked non-binding.
        -
        Practice lap record
        -
        raw laps, flagged practice; never linked to seeding.
        -
        Hot-lap board
        -
        per pilot (structured modes) or per frequency/node (free-fly): the recent laps (last ~5) and the fastest lap, with an optional class tag.
        +
        Open-practice round (open_practice)
        +
        As built: a round on the event with no class and no win condition; its seeding is the + active-channels set (the chosen node seats) and an optional + time_limit_secs.
        +
        Open-practice heat (auto-created)
        +
        one heat whose competitor refs are the active channels (node-i); carries no + channel assignment (the channels are the seats) and is not scored.
        +
        Per-channel practice board (in-memory)
        +
        per active channel: laps, last lap, and best lap — held in memory and cleared on heat change; + never logged, never linked to seeding.
        -

        Reuses the frequency/seat pool and track defined in Setup.

        +

        Reuses the primary timer's channels (its node seats and + available channels) and the track from + Setup.

        Open decisions

          -
        1. Shared queue engine. (decided) One shared rolling-queue - engine across practice and qualifying — built once; - practice runs it non-binding, qualifying adds binding scoring.
        2. -
        3. Practice-lap persistence. Are practice laps event-local and - discardable, or retained as career "practice" records? Open-data value versus noise.
        4. -
        5. Hot-lap board scope. One event-wide board, per-class boards, or - per-track.
        6. +
        7. Resolved — practice is the open_practice round, not a + phase with modes. What shipped is the single channel-seated free-fly run; the + three-mode model (rolling / scheduled / free-fly) and the shared rolling-queue engine were + not built (ZippyQ rolling fills + PWA self-register are post-v0.4).
        8. +
        9. Resolved — practice-lap persistence: in-memory, discardable. + Open-practice laps live in memory and are cleared on heat change; they are not appended to the + log and never seed qualifying.
        10. +
        11. Resolved — hot-lap board scope: per channel. The board is keyed + by the active channel / node seat, not by pilot or class (there is no roster in open practice).
        12. Presence feedback. Does practice presence feed Setup's - auto-presence — i.e. a pilot who practiced is "here"?
        13. -
        14. Frequency authority. In practice, does the system assign channels - or do pilots self-select?
        15. + auto-presence — i.e. a pilot who practiced is "here"? (Open practice has no pilot binding, so + this stays open.) +
        16. Calibration window. The per-pilot timer-calibration use of practice is + still planned, not built.
        diff --git a/docs/pipeline-qualifying.html b/docs/pipeline-qualifying.html index 0cc1ef6..4729f98 100644 --- a/docs/pipeline-qualifying.html +++ b/docs/pipeline-qualifying.html @@ -38,60 +38,100 @@

        Role

        seed.

        +
        +

        + As built (v0.4) — qualifying is a round, run live. In the race-running + redesign, “qualifying” is a round on the event whose + format is timed_qual (friendly name “Time Trials”, + renamed from “Qualifying” — D11: the format is the + time-trial scorer; qualifying is what you do with it) + or round_robin (since carved to tournaments-snapshot with the + tournament structures — 2026-06-30, PR #327), created from the Rounds+Heats stage-page like any other round and + targeting one class (single-select). Within that one round each pilot flies + multiple heats — the format's “Heats per pilot” param + (key rounds, default 3) sets how many — over the static, + channel-balanced field, and + the round's final ranking is the seeding the bracket consumes via the FromRanking + seed (top-N; the UI cut is now labeled “Take top”, bounded by the source + rounds' real field — PR #332). Live is the path that shipped; import / skip and the rolling-queue + (ZippyQ) engine are not built in v0.4. (Engine: round_engine.rs static + path + the timed_qual generator; UI: + Clients §1 Rounds+Heats.) +

        +
        +

        Capabilities

        Three ways to get a ranking

          -
        • Run it live — fly qualifying through the heat loop and score it.
        • -
        • Import it — pull a ranking from outside. A Velocidrone leaderboard - import is first-class (fixed sim tracks make this natural); MultiGP results are another - source. The import maps directly to the qualifying ranking.
        • -
        • Skip it — seed from registration order or a manual seed and go straight - to the mains.
        • +
        • Run it live (built) — fly a timed_qual (Time Trials) + qualifying round and score it (round_robin was carved to + tournaments-snapshot, PR #327).
        • +
        • Import it (planned) — pull a ranking from outside. A Velocidrone + leaderboard import is first-class (fixed sim tracks make this natural); MultiGP results are + another source. The import maps directly to the qualifying ranking. Not built in v0.4.
        • +
        • Skip it (partial) — seed a bracket from a manual / roster order + instead of a qualifying ranking (the bracket's FromRoster seed). Registration-order + and explicit-import seeds are planned.
        -

        Qualifying metric (when run live)

        -

        How a pilot's flying becomes a rank. Two metrics, both configurable:

        -
          -
        • Fastest N consecutive laps — the sum of the best N back-to-back laps, - normally flown within a time limit. N is configurable and - defaults to 3; N = 1 is simply the single best lap. The time limit is - configurable, and the usual last-lap rule applies: if a pilot crosses the - start/finish before the limit expires, that lap completes and counts even though it ends - after the buzzer.
        • -
        • Most laps in a fixed time — most laps within a set window, with total - time as the tiebreak.
        • -
        +

        Win condition = qualifying metric (when run live)

        - Position-based group / round-robin ranking is a different philosophy and lives with the - mains as a bracket format — it is not a qualifying metric - here. + How a pilot's flying becomes a rank. In the as-built engine the qualifying metric is + the round's heat win condition — there is no separate metric param. The win + condition both ends each heat and, derived from it, determines how heats roll up into the + per-pilot ranking:

        - -

        Organizing qualifying flights

          -
        • Solo / timed heats — pilots fly for time, alone or in small heats.
        • -
        • Scheduled rounds — a fixed set of qualifying rounds.
        • -
        • Rolling "fly when ready" queue — the same shared rolling-queue - engine practice uses, here in its scored form: pack limit, queue depth, - rest-between-rounds, and rounds added on demand; it assigns the next slot and - frequency/seat as the field flows.
        • +
        • Win condition (the round form's Win condition dropdown) — for a + qualifying format the form shows only the qualifying-applicable conditions: + Best lap, Best N consecutive, or Timed — Most Laps + (First to N laps is hidden). The usual last-lap rule applies to timed + windows: a lap whose start/finish crossing came before the limit completes and counts even + if it ends after the buzzer.
        • +
        • Derived ranking metric — the engine + (qual_metric_for in round_engine.rs) maps the win condition to the + ranking metric, so the two can never disagree. For timed_qual: + Best lap → best-lap, + Best N consecutive → best-consecutive, + Timed — Most Laps → most-laps. For + round_robin (carved to tournaments-snapshot, PR #327): + Timed → total-laps, otherwise + a points standing. For best-consecutive the span N defaults to 3.
        • +
        • Adjudications reach the ranking (hardened 2026-06-30..07-01) — + marshaling adjudications (DQ / TimeAdded / lap edits / throw-out / void) fold into round + ranking, round standings, class standings, and seeding via the shared + score_heat_window scorer (#329, closed #226), and DQ'd placements are + excluded from the ranking (#331).
        -

        Rounds

        +

        Organizing qualifying flights — multiple heats per pilot

          -
        • Rounds are an administrative / management construct — a way to organize - and schedule when pilots fly — not a scoring mechanism.
        • -
        • A pilot's rank always uses their best result across all rounds: the - fastest qualifying time for the fastest-N metric, or the most laps (total time as tiebreak) - for the laps metric. A bad round never counts against you.
        • -
        • Rounds can be added on demand as pilots keep flying.
        • +
        • One qualifying round per class — the round targets a single class + (single-select), and within it every member flies the same number of heats.
        • +
        • “Heats per pilot” param — the format's + rounds param (labeled “Heats per pilot”, default + 3) sets how many heats each pilot flies; the + engine builds a deterministic, channel-balanced plan (one pilot per distinct + channel per heat, capped by the timer's node count) across all of them.
        • +
        • Chunking large fields (#331) — when the class field exceeds + heat_size, timed_qual chunks each pass into multiple heats of + heat_size (heat ids tq-r{n}-h{m}), so every pilot still flies the + configured number of heats.
        • +
        • Static channel mode — qualifying uses fixed per-pilot channels (each + member's channel from the roster), so heats are channel-balanced rather than per-heat allocated + (see Channel Model).
        • +
        • Rolling “fly when ready” queue (not built) — the + shared rolling-queue / ZippyQ engine is deferred to post-v0.4.

        Output → seeding

        - Each pilot's best result across all rounds sets their metric; the class is ranked; every - pilot gets a qualifying position. That ranking is the seed input the mains are generated from. + The qualifying round's final ranking (its generator's ranking, per the metric derived from the + win condition) gives every member a qualifying position. A bracket round then seeds from it with the + FromRanking rule, taking the top-N (the UI cut labeled + “Take top”, bounded by the source rounds' real field) — that is the seed + input the mains are generated from.

        Sim vs IRL

        @@ -106,34 +146,33 @@

        Sim vs IRL

        Entities & data implied

        What this phase needs to exist (conceptual — not a schema; the Architecture doc formalizes it):

        -
        Qualifying config (per class)
        -
        mode (run / import / skip); metric (type + N and time limit, or the time window); the last-lap rule; rounds (organizational only — rank always takes a pilot's best across all rounds).
        -
        Rolling queue
        -
        the shared rolling-queue engine in its binding/scored variant.
        -
        Qualifying run
        -
        a heat-loop run, scored (binding).
        -
        Qualifying result
        -
        per pilot per round: the metric value, plus the raw laps.
        -
        Qualifying ranking
        -
        derived — aggregated metric → qualifying position per class; the seed output for the mains.
        -
        Import source mapping
        -
        Velocidrone leaderboard / MultiGP results → ranking.
        +
        Qualifying round (per class) — timed_qual (round_robin carved to tournaments-snapshot, PR #327)
        +
        As built: a round targeting one class; carries a heat win condition + (which is the qualifying metric — derived, not stored separately) and a + rounds param labeled “Heats per pilot” (default 3). Channel + mode is Static (fixed per-pilot channels).
        +
        Qualifying heat (scored)
        +
        a channel-balanced heat over the class members; scored under the round's win condition and + appended to the log.
        +
        Qualifying ranking (derived)
        +
        the round generator's final ranking → qualifying position per member; the seed output a + bracket consumes via FromRanking (top-N).
        +
        Import source mapping (planned)
        +
        Velocidrone leaderboard / MultiGP results → ranking. Not built in v0.4.
        -

        Reuses the frequency/seat pool, track, and roster from Setup.

        +

        Reuses the primary timer's channels (Channel Model), + the track, and the per-class roster / membership from + Setup.

        Open decisions

          -
        1. "Fastest N laps" definition. Consecutive laps (the default, - MultiGP-standard) versus the best N individual (non-consecutive) laps — should both be - selectable?
        2. -
        3. Tiebreakers. Within a metric — fastest-N ties broken by the next - best lap; most-laps ties broken by total time.
        4. +
        5. Rank aggregation across heats. Confirm how each pilot's multiple + qualifying heats roll into one ranking per the chosen metric (best-of vs aggregate), and the + tiebreakers (next-best lap; total time for most-laps).
        6. Holeshot handling. Whether the holeshot / first partial lap is excluded from the metric (consistent with how the engine treats it).
        7. -
        8. Import + live interaction. Can a class import a ranking and then - also fly (override / merge), or is it one or the other?
        9. -
        10. Solo vs heats. Supporting both solo time-trial and heat-based - timed qualifying, and how each is scheduled.
        11. +
        12. Import + live interaction (deferred). Import / skip seeds and + whether a class can import then also fly — deferred with the import path (post-v0.4).
        13. Cross-class scheduling. Multiple classes qualifying on one timer/track during the day (cross-ref multi-class scheduling — decided in Mains).
        diff --git a/docs/pipeline-season.html b/docs/pipeline-season.html index f5a0442..d2f8dcc 100644 --- a/docs/pipeline-season.html +++ b/docs/pipeline-season.html @@ -106,7 +106,13 @@

        Open decisions

      • Participation points. Attendance / participation points and minimum-events thresholds.
      • Cross-chapter identity. The same pilot across chapters (ties to the - Setup identity decision).
      • + Setup identity decision — now an app-level pilot directory + carrying external ids, #74). +
      • Resolved — Per-class identity across events. A season + aggregates by class, and the app-level Class registry's nine locked built-ins + carry fixed ids, so the “same class” resolves cleanly across events + without name-matching (Setup). Custom classes are matched by + their id within the Director's catalog.
      • diff --git a/docs/pipeline-setup.html b/docs/pipeline-setup.html index aaeb863..967910e 100644 --- a/docs/pipeline-setup.html +++ b/docs/pipeline-setup.html @@ -40,6 +40,21 @@

        Role

        consume.

        +
        +

        + As built (v0.4) — “configure once, select per event.” The pieces + an event is assembled from now live as app-level registries the RD manages + once on the console home hub, then selects from per event: a + Pilot directory (#74), a Class registry (#73's sibling) with + nine locked built-in classes, and a Timer registry (#73). An event holds its + own selection from these registries plus its per-event state, persisted in the + event's SQLite sidecar meta table so events survive a restart (#115). The + sections below describe the registries and how an event selects from them. (IA in + Clients §1; the registry vs. per-event-selection split in + Architecture §4.) +

        +
        +

        Capabilities

        Define the event

        @@ -47,43 +62,68 @@

        Define the event

      • Identity — name and date.
      • Classes / categories. An event runs one or more classes, and each class is a self-contained competition — its own roster subset, - qualifying, bracket, and results. The predefined set is drawn from MultiGP: - Spec, Pro Spec, Freedom Spec, Street League, Open, Tiny Whoop, Tiny Trainer, - plus a Custom/Other free-text class. A class is a - categorization; GridFPV does not enforce equipment/hardware specs — - those rules live with the league, not the app.
      • -
      • Track selection. A physical track layout (IRL) or a Velocidrone spec - track and its track id (sim).
      • + qualifying, bracket, and results. Classes come from the app-level Class registry + (as built, v0.4): each class carries a name, a ClassSource + provenance tag (MultiGP | Five33 | FreedomSpec | StreetLeague | UDL | Custom | Other), + a reference, and a description. The registry ships nine locked built-in classes with + fixed ids — MultiGP Open / Pro Spec / Whoop / Micro, Five33 Tiny + Trainer, Freedom Spec, Street League, and UDL Igniter / Shrieker + — whose stable ids give a class one cross-event identity (so per-class season standings line up + across events without fuzzy name-matching). Users create Custom classes + (full CRUD); built-ins are read-only. An event simply selects which classes it + runs. A class is a categorization; GridFPV does not enforce equipment/hardware + specs — those rules live with the league, not the app. +
      • Track selection. One track per event (resolved): + a physical track layout (IRL) or a Velocidrone spec track and its track id (sim). All classes + in an event share that one track.
      • Format configuration. Which phases this event runs — practice on/off, qualifying mode (run live / import / skip), and the mains format. The pipeline is configurable per event, not fixed.
      • -
      • Guided setup wizard. Make the common path effortless (a table stake - against the incumbents) while leaving every choice editable afterward.
      • +
      • Pilot directory (app registry). As built (#74): a pilot is + defined once in the app-level directory — callsign plus name, phonetic, + team, color, country (ISO code), vtx_types (a multi-select of + Analog | HDZero | DJI | Walksnail | Other), MultiGP and Velocidrone ids, and + free-form custom attributes (any field is clearable via a null edit). An event + selects pilots from the directory rather than re-typing them.
      • +
      • Guided setup wizard (built). Make the common path effortless (a + table stake against the incumbents) while leaving every choice editable afterward. As built the + wizard is a thin guided first pass over the event workspace's stage-pages — + Classes & Roster → Timer & channels → First round → Review, + embedding the same stage components — and is Next-only (Back / Skip / Next / + Finish); every selection auto-saves, so there are no Save buttons. The Review + step is a live readiness check (a class selected, a pilot placed, a timer chosen, a round + defined) but never blocks finishing.
      -

      Assemble the field — intake

      -

      Registration is mapping pilots into an event-class roster. Four intake paths, all first-class:

      +

      Assemble the field — roster = per-class membership = presence

      +

      + Presence is membership (resolved). Putting a pilot into an event's + class roster is declaring them present for that class — there is no + separate check-in flag layered on top. A pilot can be in more than one class's roster; each + class roster is its own self-contained field. How a pilot lands in a roster differs by mode: +

      +
      +

      IRLThe RD adds pilots to a class roster (selecting + from the pilot directory) and binds each timer node/seat to a pilot, so live + timing lands on the right pilot. Membership is a deliberate RD act.

      +

      Sim (Velocidrone)Auto-membership from the + adapter's CompetitorSeen: players the room reports are auto-added to the roster and + auto-bound when their sim name matches a directory pilot. The RD can still modify or remove + anyone. This is the “everyone in the room at 8 PM gets bracketed” flow.

      +
      +

      Other intake paths remain first-class where they apply:

        -
      • RD manual entry — the race director adds and edits pilots directly. The - always-available baseline.
      • -
      • Self-registration (racer PWA) — pilots register themselves from their - phone on the LAN against the Director (e.g. scan a QR, enter a callsign). Reduces RD load.
      • +
      • RD manual entry — selecting directory pilots into a class roster; the + always-available baseline (IRL and sim).
      • +
      • Self-registration (racer PWA) (deferred) — pilots registering + themselves from a phone on the LAN. Useful but not in the v0.4 race-running slices; deferred + alongside ZippyQ self-register.
      • MultiGP RaceSync import — pull a chapter's roster (pilots and their - MultiGP ids) from RaceSync. First-class league integration.
      • + MultiGP ids) from RaceSync into the directory. First-class league integration (v0.8).
      • Velocidrone / CSV import — import from a Velocidrone room roster or a leaderboard CSV; the leaderboard variant also seeds qualifying.
      -

      Check-in & presence

      -
        -
      • Check-in — mark who is actually here, per class. A pilot on the roster - is not necessarily present.
      • -
      • Auto-presence — pilots a timer or sim room currently sees can be - activated automatically. This powers the "everyone in the room at 8 PM gets bracketed" - flow and relies on the adapter presence capability.
      • -
      • Manual activate / prune — the RD can always override presence by hand.
      • -
      -

      Pilot-identity binding

      • Bind each timing source's local pilot reference — a node/seat index, a sim player name, a @@ -92,12 +132,45 @@

        Pilot-identity binding

        callsign), which is what makes the four intake paths reconcile to one pilot.
      +

      Channels — defined on the timer, dynamically tuned

      +

      + Channels belong to the timer, not the event (resolved). Rather than declare a + free-floating “frequency pool” at the event, GridFPV models channels as a property of + the selected timer: +

      +
        +
      • A built-in standard FPV channel catalog — the common bands/channels every + analog pilot knows (e.g. Raceband), shipped with the app.
      • +
      • Each timer adapter declares its channel capability — either + fixed (only a built-in allowed set, e.g. a hardware timer locked to certain + channels) or flexible (the standard catalog plus arbitrary custom raw-MHz entries, + as RotorHazard allows). A sim timer (Velocidrone) declares none.
      • +
      • Channels are configured in the timer's own config — what this timer can + tune to is set up once with the timer in the registry (its available_channels pool), + not re-declared per event.
      • +
      • The timer's node count caps the field per heat — but the channel pool can exceed + it. You can assign at most as many pilots to a heat as the timer has nodes + (node_count), yet a timer may offer more available channels than nodes (a + 4-node timer can list 8 channels). Channels are not nodes; the full model has + its own deep dive (Channel Model).
      • +
      +

      + Per-pilot channels (the Classes + Roster page's channel dropdown, fed by the + primary timer) and per-heat channel assignment (engine first-fit + + manual) plus the dynamic tuning of the timer's nodes at race time are detailed in + Channel Model, Mains, and + Race Engine §5: the engine decides who flies on which channel, and + the adapter applies the tuning to the hardware. +

      +

      Sim vs IRL

      -

      IRLDeclare the frequency / seat pool; assign transponders or - timer seats; capture waivers and the safety briefing.

      -

      SimSelect the Velocidrone spec track; no frequency pool and no - waivers; intake and presence come from the Velocidrone room.

      +

      IRLAdd pilots to class rosters and bind transponders / timer + nodes; the selected timer defines the available channels (its capability + node count); capture + waivers and the safety briefing.

      +

      SimSelect the Velocidrone spec track; no channels (the sim timer + declares no channel capability) and no waivers; roster membership and presence come + automatically from the Velocidrone room.

      Entities & data implied

      @@ -107,33 +180,55 @@

      Entities & data implied

      Event
      -
      name, date, format-config (which phases are enabled, qualifying mode, mains format), and one or more Classes.
      -
      Class
      -
      class type (predefined enum + custom); scopes its own roster, qualifying, bracket, and results; references a track.
      -
      Pilot (global registry)
      -
      callsign, name, and the external ids it's known by (Velocidrone uid, MultiGP id, …).
      -
      Roster entry (pilot ↔ event-class)
      -
      presence / check-in flag, seed inputs (qualifying position / metric), and the per-source identity binding (pilot_ref).
      +
      name, date, one track, format-config (which phases are enabled, qualifying mode, mains + format), the selected Classes, and per-event state persisted in the event's sidecar + meta table (#115).
      +
      Class (app registry → per-event selection)
      +
      Registry: name, ClassSource provenance + (MultiGP | Five33 | FreedomSpec | StreetLeague | UDL | Custom | Other), reference, + description; nine locked built-ins with fixed ids for stable cross-event identity, plus + user-created Custom classes. Per event: an event selects which classes it runs; each + selected class scopes its own roster, qualifying, bracket, and results.
      +
      Pilot (app directory)
      +
      callsign, name/phonetic, team, color, country (ISO), vtx_types (multi-select), + MultiGP + Velocidrone ids, and custom attributes — the external ids are what reconcile the + intake paths to one pilot (#74).
      +
      Roster entry (pilot ↔ event-class) = membership = presence
      +
      membership in a class roster is presence; carries seed inputs (qualifying position / + metric) and the per-source identity binding (node/seat or sim name → pilot). IRL = RD-added; + sim = auto from CompetitorSeen.
      Track
      -
      a physical layout reference (IRL) or a Velocidrone spec track + id (sim).
      -
      Frequency / seat pool
      -
      (IRL) the channels or timer seats available to assign during heats.
      +
      one per event — a physical layout reference (IRL) or a Velocidrone spec track + id (sim).
      +
      Timer (app registry) & its channels
      +
      one of Mock or RotorHazard { url }. The selected timer + defines the channels — a standard catalog + the adapter's fixed/flexible capability + custom + raw-MHz, held as an available_channels pool — and a node_count that caps + pilots-per-heat (the pool may exceed it). Channels live in the timer config, not an event-level + pool (Channel Model).

      Open decisions

        -
      1. Pilot-identity model. One global Pilot carrying multiple external - ids, or per-source identities? How do we dedupe / merge a pilot who arrives via Velocidrone, - MultiGP, and manual entry?
      2. -
      3. Track scope. Is the track set per event or per class? Multi-class - events sometimes share a track and sometimes don't.
      4. -
      5. Self-registration trust. Do self-registered pilots need RD - approval before they're on the roster? How does a racer's device pair with and trust a - Director on the LAN? (Links to the mDNS pairing decision.)
      6. -
      7. Class semantics. Label-only (proposed) versus storing — or even - enforcing — equipment specs per class.
      8. +
      9. Resolved — Pilot-identity model. One global Pilot in the + app-level pilot directory (#74) carrying the external ids it's known by + (Velocidrone uid, MultiGP id, callsign), which is what reconciles a pilot arriving via + multiple intake paths. Events select directory pilots; sim auto-membership auto-binds + by name match.
      10. +
      11. Resolved — Track scope: one track per event. A single track is + set on the event and shared by all its classes (the redesign chose one-track-per-event for + simplicity; multi-track is not a v0.4 concern).
      12. +
      13. Self-registration trust (deferred). Whether self-registered + pilots need RD approval, and how a racer's device pairs with a Director on the LAN — deferred + with the PWA self-register path (post-v0.4).
      14. +
      15. Resolved — Class semantics & identity: label-only, with stable + built-in ids. A class is a categorization (label), not an equipment-spec enforcer; the + Class registry's nine locked built-ins carry fixed ids so a class keeps one + identity across events (satisfying the per-class season-identity need; + Season). Users add Custom classes; provenance is recorded + via ClassSource.
      16. MultiGP import. Does roster import require connectivity at setup - time, and where does MultiGP identity get reconciled against existing pilots?
      17. + time, and where does MultiGP identity get reconciled against existing pilots? (v0.8; + reconciliation keys on the directory's MultiGP id.)
      18. Multi-class scheduling. (decided) How parallel class competitions share one timer/track is settled in Mains: sequential by class by default, with a unified diff --git a/docs/protocol.html b/docs/protocol.html index 40c5f3a..64ed67b 100644 --- a/docs/protocol.html +++ b/docs/protocol.html @@ -30,7 +30,9 @@

        Protocol

        does not redefine the projections themselves — those belong to the engines that derive them (Race Engine, Cloud & Season) — nor the timing vocabulary - (Timer Adapters). It defers the cross-event registry + (Timer Adapters). The live race-state projection, its + server-time race clock, and the open-practice overlay get their own deep-dive in + Live State & the Race Clock. It defers the cross-event registry and account model to Cloud & Season, and the overlay catalogue to Streaming. Trust boundaries are fixed in Architecture §9; the wire mechanics are decided here. @@ -59,10 +61,13 @@

        1. What the protocol serves — projections, not the log

        The projections the contract serves, each its own addressable resource:

        Live race-state
        -
        The current heat and its loop state (Scheduled → Staged → Armed → Running → - Landed → 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.
        +
        The current heat and its loop phase (Scheduled → Staged → Armed → Running → + Unofficial → Final, the projected HeatPhase; see + Race Engine §2), the active pilots and their live lap progress, + the running order, the on-deck heat, and the server-authoritative race clock + (race_started_at / race_ended_at). The latency-sensitive core every + overlay and spectator watches. Its full fold, the clock model, and the open-practice overlay + are the subject of Live State & the Race Clock.
        Lap lists
        Per-pilot, per-heat laps and splits, already folding marshaling adjudications (void, insert, adjust). What the engine derives, not what the timer reported.
        @@ -91,6 +96,19 @@

        1. What the protocol serves — projections, not the log

        contract stable while the log vocabulary and projection internals evolve underneath.

      +
      +

      + As built. Most of what was deferred in early v0.4 is now wired: the + registration-binding event (CompetitorRegistered, surfaced in + LiveRaceState.progress[].pilot), the event-setup commands, and the + LiveRaceState race clock (race_started_at / + race_ended_at, server-time authoritative — see + Live State & the Race Clock) all ship. Per-gate + splits remain a later refinement (the lap-count + last-lap pair is the live core). + The Tauri shell + 3-OS single-binary packaging are still not built (#57/#58) + — the RD console runs as a web app served by the Director, not yet a native window. +

      +

      2. The snapshot read model

      @@ -125,6 +143,59 @@

      2. The snapshot read model

      apart.

      +

      Endpoint surface (as built)

      +

      + The concrete HTTP/WS surface the Director serves today, behind the + EventRegistry storage seam. Every read / realtime / control surface is + event-rooted under /events/{eventId}/… (issue #72): the handler resolves + eventId to that event's own log (its AppState) before serving, and an + unknown eventId is a typed ProtocolError 404 (UnknownScope), + the same shape a wrong scope-within-event gets. Only the events lifecycle, the app-level + directories (timers / pilots / classes), the static config reads, and GET /health + sit at the root. +

      +

      Root surface — lifecycle, directories, static config:

      + + + + + + + + + + + + + +
      EndpointWhat it serves
      GET /healthliveness
      GET /events · POST /events · DELETE /events/{id}list (Practice first) / RD-gated create / RD-gated permanent delete (Practice can't be deleted)
      GET·PUT /active-eventthe Director's active event (open read; RD-gated write) — ActiveEvent / SetActiveEventRequest
      GET·POST /timers · PUT·DELETE /timers/{id}app-level timer registry: Mock + RotorHazard, carrying channel_capability / node_count / available_channels (§ below). Open read; RD-gated writes; Mock undeletable
      GET·POST /pilots · PUT·DELETE /pilots/{id}app-level pilot directory (typed fields only — no attributes bag). Open read; RD-gated writes
      GET·POST /classes · PUT·DELETE /classes/{id} · PUT /classes/{id}/hiddenapp-level class directory incl. the hide/archive visibility toggle. Open read; control-gated writes
      GET /formats · GET /channelsstatic compiled-in config: the valid format names and the standard FPV channel catalog (band/channel ↔ raw MHz). Open reads, no token
      +

      Per-event surface — everything under /events/{eventId}:

      + + + + + + + + + + + + + + + + + + + +
      Endpoint (under /events/{eventId})What it serves
      PUT /classes · PUT /classes/{class}/membershipper-event class selection and per-class roster membership (RD-gated)
      POST /rounds · PUT·DELETE /rounds/{round}per-event class-tagged rounds (RD-gated) — RoundDef / NewRoundReq / UpdateRoundReq
      GET /heatsthe round-tagged scheduled-heats list (HeatSummary[]) the Heats UI reads. Open read
      GET /rounds/{round}/ranking · GET /classes/{class}/standingsa round's per-pilot ranking and a class's aggregated standings. Open reads
      PUT /roster · POST·DELETE /roster/{pilot}per-event roster, wholesale or single-pilot (RD-gated)
      PUT /timers · PUT /primary-timerper-event timer selection and the primary timer (RD-gated)
      GET /snapshot/event/{event}event-scope live race-state snapshot
      GET /snapshot/class/{event}/{class}class-scope snapshot (a real class filter — only that class's heats)
      GET /snapshot/heat/{heat}heat-scope snapshot
      GET /snapshot/pilot/{event}/{pilot}pilot-scope snapshot
      GET /streamWS change stream (SubscribeRequestStreamMessage)
      GET /control (WS) and POST /control (one-shot)control-gated; CommandCommandAck (§5)
      POST /auth/join-tokenRD-gated; mints a read-only JoinTokenResponse { token } — the QR/URL spectator capability (§5)
      +

      + Unknown API routes under any known API tree return a typed + ProtocolError 404 (an api_fallback) — 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

      After the snapshot, the client opens a WebSocket and receives a stream @@ -140,6 +211,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

      @@ -162,6 +237,23 @@

      Sequence, ordering & resume

      cursor. Re-snapshot is always correct because projections are recomputable; the stream is an optimization, never the source of truth.

      +
      +

      + As built — two integers, deliberately distinct. The design above commits to + a projection sequence as the public ordering; the implementation realizes this as + two integers a client persists together. The resume from cursor + (the Cursor the snapshot hands back) is the log length + at snapshot time — the offset of the first event after the snapshot — and is what a + reconnect re-presents to resume. The change-stream sequence on each + ChangeEnvelope is a separate per-stream counter starting at + 1 and advancing once per emitted envelope (one log append may fan out into + several envelopes, or none this scope subscribes to). A client renders by + sequence order and resumes by the from log-offset cursor; + the two coincide numerically only by accident. This keeps the log offset usable as the resume + key while the per-stream sequence stays the public ordering. Detailed in + Live State & the Race Clock §3. +

      +

      Ordering guarantees, stated plainly:

      @@ -179,6 +271,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)
    @@ -240,8 +346,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:

    @@ -263,53 +371,112 @@

    4. Subscription scoping

    5. Auth & authorization

    - Access splits into read and control, following the - trust boundaries fixed in Architecture §9. This doc - decides only the wire mechanics. + Access splits into two capabilities — control (run the race) and + read (watch it) — following the trust boundaries fixed in + Architecture §9. This doc decides the wire mechanics. The + model below is the decided evolution of the earlier "RD bearer token + + read-only join token" sketch: loopback-trust replaces the local RD login, the + old RD token becomes a remote-access passphrase setting, and the join token is + the basis of a first-class read tier.

    + +

    Control — loopback is trusted, remote is opt-in

    -

    Reads are open (or lightly tokened) on the LAN. Anyone on - the venue LAN can fetch snapshots and subscribe to the read stream — that's the point of - a local spectator/overlay experience with no internet. An optional lightweight join - token (printed as a QR code, baked into the URL) can gate casual access to the event - without standing up accounts; it grants read scope only and never confers control.

    +

    Loopback is trusted; there is no local auth. The RD console + runs on the Director's own machine (the Tauri app, or the v0.4 web app served at + localhost). A request whose source address is loopback is the operator at the + keyboard by definition, so it gets full control with no login, no token, + no passphrase — the RD opens the app and is in. This is enforced by source-IP + middleware (peer.ip().is_loopback()), and it is safe because the OS drops + spoofed-loopback "martian" packets at the kernel before they ever reach the server: a + remote host cannot forge a loopback source address. The practical consequence is that + there is no local auth UI — no login screen in front of the event picker, + nothing to type to start running a race on your own machine.

    -

    Control is authenticated and Director-local. Race control, - marshaling, and configuration require the RD's authenticated role and run only against - the Director. The control path is a distinct, privileged set of endpoints / a privileged - channel on the same connection — bidirectional by nature (commands up, acknowledgements - and resulting state down), which is part of why the RD console wants WebSocket - regardless of the spectator-transport decision.

    -

    Future: the control path may serve one or more authenticated - RD-role clients on the LAN — the desktop RD console and/or a future mobile - RD-control surface the RD uses to run the race from their phone (e.g. start the - heat from their seat). It still requires the RD role (with stricter auth for a mobile - device) and, like all control, is Director-only and never exposed on the - Cloud.

    +

    Remote control is opt-in and off by default, gated by a passphrase. + Reaching the control path from anywhere other than loopback — the RD's PWA on a phone over + the LAN, so the RD can start, abort, or advance a heat from their seat — is a deliberate + choice the RD makes, disabled by default. When the RD enables it, they set + a passphrase; a non-loopback request must present it to touch control. The + same source-IP middleware that grants loopback its free pass is what holds remote requests + to the passphrase gate. Control authority stays Director-only and LAN-only + either way — enabling remote control only adds a second place to exercise the existing + Director role over the LAN; it never widens who controls a race or exposes control + to the Cloud.

    +

    This is what the earlier "RD bearer token" becomes: not a login a local RD must clear, + but a remote-access passphrase the RD opts into for off-machine control.

    + +

    Read — a first-class tier for pilots and spectators

    +
    +

    Reads are a first-class, scoped capability — required for the pilot apps. + Reading is not an afterthought of control; it is the tier every pilot and spectator + client uses. A read client gets a lightweight, scoped capability (a Scope::Pilot + or event scope, §4): a pilot app shows that pilot's schedule, laps, and bracket + position; a spectator or overlay watches the event or a heat. On the LAN this read is open by + default, optionally gated by a lightweight join token (printed as a QR code, + baked into the URL) when an event wants to gate casual access — the join token grants + read scope only and never confers control. This read tier is the basis of the + Cloud account's read authorization too (§ below, and Cloud & Season §7).

    +
    +

    The Cloud authenticates accounts but never controls. Off-LAN viewers reach the Cloud with accounts; the Cloud serves the identical read - contract and relays. It exposes no control path at all — control - authority never leaves the Director (Architecture §9). - An RD does not run a race "through the Cloud"; the Cloud only ever reflects what the - Director already decided.

    + contract and relays, mapping an account onto the same scoped read tier. It exposes + no control path at all — control authority never leaves the Director + (Architecture §9). An RD does not run a race "through the + Cloud"; the Cloud only ever reflects what the Director already decided.

    Same read contract, different gate. The read half of the protocol is - byte-identical on both transports; only what stands in front of it differs — open / + byte-identical on every transport; only what stands in front of it differs — open / join-token on the LAN, account auth on the Cloud. Because the storage trait makes the server crate identical on both sides, "authorize this read" is a thin policy layer, not a fork of the contract. The exact token/session format (opaque token, signed JWT, - cookie session) is deliberately left open below. + cookie session) is recorded in Open decisions. +

    +
    + +

    TLS posture

    +

    + The two access tiers above carry a credential only in two cases — the remote-control + passphrase, and Cloud account auth. Where that credential (or any traffic) travels over an + untrusted network, it must be encrypted. The posture is staged with the transports: +

    +
    +
    Local / LAN (now)
    +
    Plain HTTP is acceptable. The venue LAN is a trusted network and loopback control + carries no credential at all, so there is nothing to protect in clear on the wire at this + stage.
    +
    PWA / remote control (v0.5)
    +
    A TLS plan is required before the remote-control passphrase or the + installable PWA ships — self-signed certificates or a challenge-response scheme — so the + passphrase never crosses untrusted WiFi in the clear. (A secure context is also what + unlocks the PWA's install / service-worker / push features; see + Clients §4.)
    +
    Cloud (v0.7)
    +
    TLS is mandatory — HTTPS / WSS end-to-end, with zero unencrypted traffic over + the internet, ever. This is a hard requirement enforced in the architecture, not a + recommendation. Cloud account auth and the read stream both ride encrypted transport without + exception. See the callout below and Cloud & Season.
    +
    +
    +

    + Mandatory Cloud TLS is non-negotiable. Every byte the Cloud serves or + receives over the public internet — account auth, the read snapshot and change stream, the + Director→Cloud log push — travels over HTTPS / WSS, end-to-end, always. + There is no "plain-HTTP Cloud" mode and no exception for development convenience reachable on + a public address. This is stated as a hard, enforced requirement, not a best-effort "should."

    +

    IRLThe control path is exercised constantly — staging, - frequency assignment, marshaling rulings, re-runs — all from the authenticated RD - console.

    + frequency assignment, marshaling rulings, re-runs — almost always from the loopback RD + console; remote control is the occasional "RD is racing too" case.

    SimSame control path, lighter use — mostly start/abort and discard-and-re-run; no frequency or signal marshaling.

    @@ -335,6 +502,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. +

    +

    7. Versioning & evolution

    @@ -400,44 +579,75 @@

    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.
  • -
  • 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.
  • + implementation. As built (v0.4): the retained window is 256 log offsets; + an older cursor gets a ReSnapshotRequired / StaleCursor response. +
  • RESOLVED — Access model: loopback-trust control + opt-in remote passphrase + first-class read tier. + Control is granted to loopback with no auth (source-IP middleware, + peer.ip().is_loopback(); safe because the OS drops spoofed-loopback martians); + remote control is opt-in, off by default, gated by an RD-set passphrase; and + read is a first-class scoped tier every pilot/spectator client uses, open or + join-token-gated on the LAN (§5). The old local "RD token" is therefore a remote-access + passphrase setting, not a login — there is no local auth UI. Credential format: the + passphrase and Cloud account auth use opaque bearer tokens backed by a + revocable server-side session, with the lightweight signed join-token granting read scope + only; revisit JWT only if stateless Cloud scaling later demands it. Trust boundaries are fixed + in Architecture §9. As built (v0.4): opaque + 256-bit bearer tokens in an in-memory revocable TokenStore, with roles + Rd and ReadOnly; loopback-trust and the remote passphrase land with + the auth slice (#80), and the PWA remote-control path follows in v0.5.
  • +
  • RESOLVED — TLS posture: HTTP on the trusted LAN, mandatory TLS on the Cloud. + Plain HTTP is acceptable on the local/LAN trusted network (loopback control + carries no credential); a TLS plan is required for the v0.5 PWA / remote control + (self-signed or challenge-response) so the passphrase never crosses untrusted WiFi in clear; + and Cloud TLS is mandatory and non-negotiable — HTTPS/WSS end-to-end, zero + unencrypted traffic over the internet, ever, enforced in the architecture (§5, + Cloud & Season). As built (v0.4): Director serves + plain HTTP on the LAN; the PWA TLS plan is v0.5 and mandatory Cloud TLS is v0.7.
  • 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: two + distinct u64s a client persists together — the per-envelope + sequence (the public ordering, counting from 1) and the resume + cursor (the log length the snapshot returns, the offset the + client re-presents in from). The Cursor wire type renders as a plain + TS number (#[ts(as = "f64")]; bounded well below 2^53). See the + "two integers, distinct" callout in §3 and + Live State & the Race Clock §3.
  • 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.
    diff --git a/docs/race-engine.html b/docs/race-engine.html index 7bfcc16..5de9cbc 100644 --- a/docs/race-engine.html +++ b/docs/race-engine.html @@ -24,9 +24,13 @@

    Race Engine

    This doc owns the heat loop, the format / generator - interface, and how heats are scored and advanced. It - defers the exact rules of each named format (MultiGP P9–P16, FAI, Chase-the-Ace, - ZippyQ specifics) to per-format specs written as they're built; here we define the + interface, and how heats are scored and advanced. The + heat FSM has a deep-dive of its own — the six-phase lifecycle, the runtime clock + that drives the auto-transitions, and the off-ramps — in + Heat Lifecycle; the engine-area decisions and + their rationale are logged in Decisions. It defers the + exact rules of each named format (MultiGP P9–P16, FAI, Chase-the-Ace, ZippyQ + specifics) to per-format specs written as they're built; here we define the interface they plug into. It builds on canonical events from Timer Adapters and the append-only spine from Architecture §3. When this doc and the @@ -62,42 +66,76 @@

    1. Where it sits

    2. The heat loop

    - One reusable state machine runs every heat, in every phase. Each transition is an - appended race-state event, so a heat's whole history — including an abort or a re-run — - is reconstructible from the log, and live race control is just commands that drive - these transitions. + One reusable state machine runs every heat, in every phase — six states: + Scheduled → Staged → Armed → Running → Unofficial → Final. Each transition + is an appended race-state event, so a heat's whole history — including an abort or a + re-run — is reconstructible from the log, and live race control is just commands that + drive these transitions. The FSM itself is pure (it reads no clock and + rolls no dice); the two middle forward steps are appended by the Director's + runtime clock, not by an RD command. The full deep-dive — the runtime + clock, the start procedure, and the grace-window completion — is + Heat Lifecycle; this section is the engine-level summary.

     stateDiagram-v2
       [*] --> Scheduled
    -  Scheduled --> Staged: stage
    -  Staged --> Armed: arm
    -  Armed --> Running: start
    -  Running --> Finished: time elapsed / all landed
    -  Finished --> Scored: score
    -  Scored --> [*]: advance
    -  Staged --> Scheduled: abort
    -  Armed --> Staged: abort
    -  Running --> Staged: abort / restart
    -  Scored --> Scheduled: discard & re-run
    +  Scheduled  --> Staged     : Stage
    +  Staged     --> Armed      : Start (runs the start procedure)
    +  Armed      --> Running    : (auto) countdown elapsed / SkipCountdown
    +  Running    --> Unofficial : (auto) win condition + grace / ForceEnd
    +  Unofficial --> Final      : Finalize
    +  Final      --> Unofficial : Revert
    +  Final      --> [*]        : Advance
    +  Staged     --> Scheduled  : Abort
    +  Armed      --> Scheduled  : Abort / Restart
    +  Running    --> Scheduled  : Abort / Restart
    +  Unofficial --> Scheduled  : Restart
    +  Final      --> Scheduled  : Discard
         

    Passes are consumed only while a heat is Running (and during a grace window - after — late crossings still count until the heat is scored; the window is configurable, - default until scored). Staging is the countdown; arm opens the gate to - detections; finish closes the race (time elapsed / all landed); score - finalizes the result; advance hands results to the format. Abort, restart, and - discard-and-re-run are first-class transitions, recorded like any other. + after it goes Unofficial — late crossings still count; the window is + configurable). Each consumed pass is stamped with the heat it was recorded + for by the Director's bridge at append time, and every fold attributes it by + that tag — never by log position alone — so a heat-span event landing mid-race (filling + the next round, marshaling a finished heat) cannot steal the running heat's laps; an + untagged pass from a legacy log keeps the old positional rule + (D20). The phases: Stage begins the (informational) staging countdown + and, for IRL, assigns frequencies; Start arms the heat and runs the + start procedure (announce → randomized hold → tone), after which the runtime + auto-advances Armed → Running; the heat closes itself when the win + condition plus grace window are met (the runtime auto-advances + Running → Unofficial); Finalize locks the result + (Final); Advance hands results to the format generator. The result + is Unofficial (not "Scored"/"Completed") while the grace window is open + and corrections may still land; Final is the locked terminal. +

    +

    + The command set. The RD's commands are Stage, + Start, Finalize, Advance, plus the off-ramps + Abort, Restart, Discard, Revert, and + the two race-day overrides SkipCountdown (force + Armed → Running now) and ForceEnd (force + Running → Unofficial now) for when the clock can't be trusted. Notably, + both Abort and Restart now reset the heat all the way + to Scheduled (the RD re-Stages it): Abort is legal + from Staged/Armed/Running, Restart + from Armed/Running/Unofficial. + Revert re-opens a finalized result (Final → Unofficial); + Discard queues a finalized heat for a clean re-run + (Final → Scheduled).

    In the log these are canonical events: a heat is created by a HeatScheduled - event (carrying its lineup — the [*] → Scheduled entry), and every transition - after is a HeatStateChanged carrying the state entered - (Staged / Armed / Running / Finished / Scored / Advanced) or the off-ramp taken - (Aborted / Restarted / Discarded). Live race control is just commands that - drive these transitions. + event (carrying its lineup, plus its class/round tag and per-pilot frequency assignments + — the [*] → Scheduled entry), and every transition after is a + HeatStateChanged carrying the recorded HeatTransition + (Staged / Armed / Running / Finished / Finalized / Advanced, or the off-ramp + Aborted / Restarted / Discarded / Reverted). The start procedure also + appends a HeatStarting { delay_ms } fact when the heat is armed, recording + the randomized hold once so a replay reproduces the start exactly (§6).

    IRLStaging assigns frequencies/channels and avoids @@ -107,10 +145,18 @@

    2. The heat loop

    3. The format / generator interface

    +

    + This section is the engine mechanics — the generators. The + RD-facing model (round types, win condition vs scoring, and how round-robin / + brackets are structures over Head-to-Head) lives in Format Model + (decision D17); generators are the machinery underneath it. +

    Formats are a pluggable registry — the same idea as adapters, for competition - structure. There is one unified generator interface, and qualifying - formats and bracket formats are both implementations of it. Concretely, a + structure. There is one unified generator interface, and the three + primitive round types (Practice / Time Trials / Head-to-Head) are all + implementations of it — as the tournament structures will be when they are rebuilt over + Head-to-Head (deferred post-v1; see the note below the table). Concretely, a FormatRegistry maps a format name + a config bag to a live generator (the competition-structure analogue of the adapter registry); the Director picks a format by name and the heat loop only ever sees the trait object (Generator::next → @@ -138,8 +184,51 @@

    3. The format / generator interface

    generator runs rounds and aggregates flights into a ranking; a bracket generator emits heats and advances seeds toward a winner. Both emit heats, consume results, and expose an ordering — so the heat loop, scheduling, and advancement machinery are - written once and shared. + written once and shared. Concretely, a Generator exposes + next(&completed) → Run(plans) | Complete and + ranking(&completed); the FormatRegistry::standard() maps each + format name to a constructor. +

    +

    The built-in formats

    +

    + The standard registry ships three primitive generators (primitives-first + release, 2026-06-30, PR #327). Each is identified by a stable + format key (used in a round's RoundDef and the log); the + RD console renders a friendly label. +

    +
    + + + + + + + + +
    Friendly nameFormat keyShape & config params
    Practiceopen_practiceOne open heat over the active channels (not pilots); no scoring (see §4). Friendly name renamed from “Open Practice” (PR #327; the wire key is unchanged).
    Time Trialstimed_qualrounds (labeled “Heats per pilot”) flights per pilot; the per-pilot best flight aggregates into a ranking under the metric derived from the round's win condition (best-lap / best-consecutive / most-laps) — no separate metric param. Large fields are chunked into heats of heat_size (heat ids tq-r{n}-h{m}; #331).
    Head-to-Headhead_to_headgroup_size (2+); a per-heat win condition; a scoring system (placement or points with a per-position table); rotations (“Heats per group”, default 1) — the same groups race N back-to-back heats, one grouping decision per round (D18; multi-rotation heat ids h2h-r{n}-h{m}, single-pass h2h-h{n}; the log scopes every generator id with the round — {round}-h2h-… — so two rounds of one format never collide).
    +

    + Registered history. The registry previously also carried the tournament + structures — round_robin, single_elim, double_elim, + multi_main, chase_the_ace (plus the bracket builders and viz) — which + were carved off devel and preserved on the tournaments-snapshot + branch (2026-06-30, PR #327), to be rebuilt as structures over Head-to-Head on the + hardened base post-v1 (Format Model §4). zippyq was + shelved earlier (D10, #218).

    +
    +

    + The qualifying → bracket carry (#84). A bracket round seeds from a + prior round's ranking: a RoundDef with a + FromRanking { source_round, top_n } seeding rule draws the + top-N of the source round's ranking (reusing advance_top_n, + the same phase-2 seeding the wholesale run_event does). The full bracket is + generated from that ranking and is then editable as event-level rounds. Rounds are + event-level and class-tagged, added as-you-go for practice / + qualifying. The UI cut is now labeled “Take top” (renamed from + “Top N advance”, PR #332) and is bounded by the source rounds' real + field. +

    +

    4. Scoring & lap derivation

    @@ -154,13 +243,65 @@

    4. Scoring & lap derivation

    projection (lap_list_marshaled), with each adjudication targeting a prior pass by its append offset (LogRef), last-writer-wins by log order; the raw passes stay byte-identical. -
  • Win condition (per-heat / format config). Most laps in a time - window, first to N laps, or best single / consecutive lap (qualifying). The engine - applies the configured condition; ties break by a recorded rule. For the timed - condition the window is anchored to a shared race clock from the start with a - hard cutoff — a lap counts only if its completing crossing falls strictly - before the window closes; an in-progress lap at the cutoff does not count.
  • +
  • Win condition (per-round scoring rule). The catalogue is the + WinCondition enum — Timed { window_micros } (displayed + "Timed — Most Laps": most laps in a set time), + FirstToLaps { n }, BestLap (fastest single lap, qualifying), + and BestConsecutive { n } (fastest N consecutive laps, qualifying). The + engine applies the configured condition; ties break by a recorded rule (earlier + completion of the deciding lap, then next-best). For the Timed condition the + window is anchored to a shared race clock from the start with a hard cutoff + — a lap counts only if its completing crossing falls strictly before the window closes; + an in-progress lap at the cutoff does not count.
  • +
    +

    The qualifying metric is the win condition (unified). + A round no longer carries a separate "qualifying metric" knob alongside a win condition. + The round's one WinCondition is the scoring rule for both per-heat results + and the cross-flight qualifying ranking: the qualifying generator + (timed_qual) ranks each pilot on their best flight under that same condition + (BestLap → fastest lap, BestConsecutive → fastest N-lap window, + Timed → most laps). Open Practice does no scoring, so its + round stores an inert win condition that is never consulted — it ends on a time limit or + the RD's ForceEnd.

    +
    +

    Penalties, disqualification & heat-void

    +

    + On top of the pure on-track scoring, a heat's adjudication events fold in: a + PenaltyApplied { TimeAdded } worsens a competitor's deciding time + (penalties accumulate); a PenaltyApplied { Disqualify } sinks them below + every non-disqualified competitor (flagged, on-track laps preserved); a + HeatVoided flags the whole result voided while still showing the standing. + These compose with marshaling corrections (void / insert / adjust on the pass stream) + without either fold knowing about the other. +

    +

    + Hardened (2026-06-30..07-01, #329/#331). Adjudications (DQ / TimeAdded / lap + edits / throw-out / void) now reach round ranking, round standings, class standings, and + seeding via the shared score_heat_window scorer — one scorer everywhere, + closing #226 (the “defensible results” bug where a ruling changed + the heat but not the standings). DQ'd placements are excluded from ranking + (#331). +

    + +
    +

    Needs further discussion — signal-level marshaling & the adapter boundary. + Today's marshaling is lap-level: void / insert / adjust on canonical passes — a single, + timer-agnostic implementation (the RD's judgment). The richer experience some timers offer (e.g. + RotorHazard's RSSI chart with adjustable entry/exit thresholds that re-derives laps from + the raw signal) is signal-level and inherently timer-specific: it depends on the timer + exposing raw signal and on that timer's detection algorithm. The intended split is to keep the + decision tooling timer-specific (a capability-gated panel; delegate the + re-analysis to the timer) while the commit stays uniform — any correction, however + decided, lands as the same canonical adjudications folded into the projection, so the engine never + cares how it was reached. Open question for the abstraction review: an adapter capability + interface (raw-signal access, re-analysis) and whether GridFPV ingests + stores + raw signal itself (offline/replayable re-analysis, but large data + re-implementing each + timer's detection) or delegates live to the timer. Provisional lean: universal + lap-level marshaling now + a capability for live timer-delegated tuning; native raw-signal ingest + is a deliberate future scope call. (The maintainer wants to use the interface hands-on before + deciding what, if anything, the marshaling data model must add.)

    +

    5. Advancement & scheduling

    @@ -171,22 +312,46 @@

    5. Advancement & scheduling

    Multi-class scheduling. Several classes can be live at once, each with - its own format, sharing scarce resources — the timer and, for IRL, the frequency pool. + its own format, sharing scarce resources — the timer and, for IRL, its channels. The engine interleaves heats round-robin across classes in a fixed rotation (so no class starves; a format that emits several heats at once has them buffered and drained one per turn rather than monopolising the serialized timer), and - allocates each IRL heat's video channels from a shared frequency pool by - deterministic first-fit in seed order — the engine's half of the split (§7.3): it decides - who flies on what channel, the adapter applies the tuning. Sim classes have no frequency - step and get an empty allocation. Both interleaving and allocation are pure functions of + allocates each IRL heat's video channels by deterministic first-fit in seed + order — the engine's half of the split (§7.3): it decides who flies on what channel, the + adapter applies the tuning. The channel set is defined by the selected timer, + not a free-floating event pool — the standard FPV catalog plus the adapter's fixed-or-flexible + capability (custom raw-MHz where allowed) — and the timer's node/slot count caps the + heat field (Setup, + Timer Adapters §5). At race time the adapter + dynamically tunes its nodes to the allocation. Sim timers declare no channel + capability and get an empty allocation. Both interleaving and allocation are pure functions of the log (§6).

    +

    + Rounds are event-level and class-tagged. The engine's rounds-and-heats + live at the event level and each round (RoundDef) is tagged with the + class(es) it is eligible for; rounds are added dynamically (practice / quali as-you-go), + and the quali→bracket step generates the full bracket from the top-N quali + ranking as a set of editable event-level rounds (the #84 carry, reusing + run_event's quali→bracket logic). A per-event round engine + wires the format generators into a live, RD-driven flow: a FillRound command + rebuilds the round's generator from its RoundDef + the event's class + membership and the round's completed heats read back from the log, then asks the generator + for the next heat — so the advance closes through the log and stays a pure function of it. +

    +

    + Static vs per-heat channels. A round declares a channel mode: a + static round (qualifying / time-trial, GQ-style) gives each member a fixed + per-membership channel and forms channel-balanced heats (one pilot per + channel per heat); a per-heat round (brackets) assigns channels per heat from the + timer's pool by first-fit. Either way the timer's node count caps the heat field. +

    Manual adjustments. Re-seeding, moving a pilot between heats before they fly, and discard-and-re-run are RD actions appended as events the generator - respects on its next step — never edits to past truth. (In v0.3 discard-and-re-run is a - real HeatTransition::Discarded event; the remaining RD-action event variants - land with the RD console.) + respects on its next step — never edits to past truth. Discard-and-re-run is a real + HeatTransition::Discarded event; Abort and Restart + both reset a heat to Scheduled for a clean re-Stage.

    6. Determinism & replay

    @@ -194,20 +359,33 @@

    6. Determinism & replay

    The engine is a pure function of the log. Given the same events, it produces the same heats, results, and standings — that's what lets projections rebuild and recorded sessions replay (the testing backbone). Anything - non-deterministic — a random draw for seeding, a coin-flip tie-break — is resolved - once and its outcome recorded, so replay never re-rolls it. In v0.3 this is the - SeedingOutcome — a recorded permutation a generator is constructed with and - applies deterministically; promoting it to a first-class log event is deferred, and will - not change the generator contract.

    + non-deterministic — a random draw for seeding, a coin-flip tie-break, the start + procedure's randomized hold — is resolved once in the runtime and its + outcome recorded as a fact, so replay never re-rolls it. The + SeedingOutcome is a recorded permutation a generator is constructed with and + applies deterministically; the start procedure's hold is logged as + HeatStarting { delay_ms } when the heat is armed, and the completion clock + decides Running → Unofficial from a pure predicate over the logged passes + (race_end_reached) — so a recorded session reaches each transition at exactly + the same point. The runtime clock (which reads wall-time and rolls the start delay) lives + outside the pure engine; everything it decides lands in the log as a fact the fold + can replay.

    7. Open decisions

    1. Resolved — Win-condition catalogue & tie-breaks. - The catalogue is timed (most laps in a window), first-to-N laps, and best-single / - fastest-consecutive (qualifying). Ties break by earlier completion of the deciding lap - (timed) or next-best lap (qualifying), and tie-break outcomes are recorded so replay is - deterministic.
    2. + The catalogue is Timed ("Timed — Most Laps"), FirstToLaps, + BestLap, and BestConsecutive (qualifying). Ties break by earlier + completion of the deciding lap (timed) or next-best lap (qualifying), recorded so replay + is deterministic. The qualifying metric is unified into this one condition (see §4). +
    3. Resolved — Heat lifecycle & the runtime clock. + Six phases Scheduled → Staged → Armed → Running → Unofficial → Final; the two + middle forward steps are runtime-driven (start procedure, grace-window completion), with + SkipCountdown/ForceEnd overrides; Abort and + Restart both reset to Scheduled. Full deep-dive in + Heat Lifecycle; rationale in + Decisions.
    4. Resolved — Start / holeshot & lap model. Start type is configurable (gate-start vs staggered); the first crossing of the start/finish gate after arming opens lap 1; splits derive per the gate model in diff --git a/docs/release-audit-2026-07.html b/docs/release-audit-2026-07.html new file mode 100644 index 0000000..9a44400 --- /dev/null +++ b/docs/release-audit-2026-07.html @@ -0,0 +1,244 @@ + + + + + + GridFPV — Release Audit (July 2026) + + + +
      +
      +

      GridFPV

      +

      Release Audit — July 2026

      +

      Internal design doc · the pre-release deep review: method, findings → fixes, and what was deferred

      +
      + +

      + Ahead of the primitives-first release, the whole system was put through a two-part audit + (2026-07-04): a seven-track deep code review (engine scoring/FSM, control + handler, projections/round engine, timer bridge & adapters, marshaling UI, console + screens/libs, wire contracts) and a live soak — a 24-pilot event driven + end-to-end over the HTTP API against a throwaway Director, including a 26-assertion + marshaling gauntlet, a semis→final bracket flow, and adversarial probes (an intruder heat + scheduled mid-race, config edits against raced rounds, garbage params). Every confirmed + finding was fixed in the same pass; the design calls each fix rests on are recorded as + D19–D24. This doc is the ledger. +

      + +
      +

      + Everything below shipped with regression tests, and the headline items were re-verified + live (the soak re-run finished 0-for-0 after the fixes). The soak driver scripts + are throwaway session tooling, but the pattern is durable: create an event, roster, and + rounds over plain HTTP; FillRound(All); drive heats + Stage → Start and let the runtime clock end them; marshal via the command + vocabulary; assert against the heat snapshots + (?projection=laps|result|audit|live) and the ranking/standings reads. +

      +
      + +

      1. Results-integrity fixes (the score would have been wrong)

      +
        +
      • Timed heats could never auto-end when nobody crossed after the buzzer; + degenerate configs (zero window / First-to-0 / zero time limit) ended heats at the start + signal. Wall-clock fallback + validation — D19.
      • +
      • Discard left the dead run scoreable: a discarded heat kept showing (and + re-scoring) the thrown-away run's laps until re-run. Discarded is now a + run-reset boundary — D19. Predicted independently by + two review tracks, then confirmed live in the gauntlet before the fix.
      • +
      • Scheduling mid-race silently dropped the running heat's laps (the + positional pass-attribution family, incl. marshaling a finished heat mid-race and selecting + an old heat absorbing later passes). Passes now carry a bridge-stamped heat tag — + D20. Live-verified with an intruder heat scheduled + mid-race: zero laps lost.
      • +
      • Brackets advanced losers: the FromHeatWinners carry used a + ranking heuristic that seeds a losing semifinalist into the final whenever losers' lap + counts differ. HeadToHead now advances each heat's winner(s), deduped, DQ never + advances. Verified by execution in review, then live: the final's lineup was exactly the + semi winners.
      • +
      • Qualifying ranking collapse: a best-lap qualifier over a round scored + under a different win condition ranked the whole field tied-at-1 alphabetically — + and seeded brackets from that order. Falls back to the condition-independent + Placement.best_lap_micros.
      • +
      • Standings counted rounds that never ran (defining finals up front + shifted standings after qualifying) and counted a DQ'd pilot's laps in + total_laps (the #339 asymmetry). Unraced rounds now contribute nothing; DQ'd + placements bank no laps.
      • +
      • Marshaling corrections missed in heat-scoped live views: filtered folds + re-enumerated offsets from 0, so a throw-out targeting a global LogRef was + ignored (or hit the wrong pass). live_state_over + + class_window_offsets preserve global offsets; the class snapshot and stream now + also fold the same window (they used to diverge).
      • +
      • Live running order contradicted the scored result on equal laps: ties + broke on last-lap duration live but last-lap completion time in the + scorer. The live fold now uses the scorer's rule.
      • +
      • Negative time "penalties" improved the penalized pilot; zero/negative + amounts are rejected, as are empty heat lineups.
      • +
      + +

      2. Runtime-integrity fixes (the race day would have wedged or lied)

      +
        +
      • Orphaned runtime clocks (the single-slot detach), full-log + replay on Director restart, the cancel-vs-expiry race, and + one transient log error killing an event's runtime — the per-heat / + tail-start / fire-time-recheck / retry model, D21.
      • +
      • Validate→append TOCTOU on every command gate — the command + serialization lock, D22.
      • +
      • Protest deadlock after Restart + double-resolve / double-void + acceptance — run-scoped protests, effectively-once rulings, + D23.
      • +
      • Raced-round config drift (editing a round re-scored Final results; + the Edit form destroyed bracket seedings; garbage params surfaced at fill time; a capped + 1000-heat runaway fill acked ok) — the raced-round freeze + param validation + + failed-ack, D24.
      • +
      • Timer-status stomp on event switch: a superseded RH connection's async + teardown marked the healthy successor Disconnected, tripping failover. The exiting driver + now yields the status cell when it is being replaced.
      • +
      + +

      3. Console fixes (what the RD saw could mislead)

      +
        +
      • Marshaling wrong-heat hazards: the ruling/protest/add-lap pilot list + fell back to the live heat's pilots (a DQ against a pilot who never flew the + marshaled heat); node-seat laps could be captioned with the other heat's pilot; projections + went stale (or crossed heats) on pin switches and failed fetches. All heat-keyed now, with + double-submit guards, positive-time input gates, and visible penalty validation.
      • +
      • Friendly-name leaks (the standing rule): Event Rounds lineups, Audit + rows, Results placements, and the JSON export could render raw node-{i} seats + for finished heats — all resolve through the shared resolver from the durable heat-window + bindings now.
      • +
      • Stale/racing reads: an event switch leaked the previous event's RSSI + trace ("evidence" under the wrong heat); Results view-switches let a slower older fetch + overwrite a newer one; a heats-read failure blanked the list silently; a stale + heatResult leaked into the JSON export; a 500 whose message contained "401" + opened the token dialog.
      • +
      • Server-anchored staging countdown: the staging clock anchored to each + console's mount instant (two consoles read different windows; late-resolving config + re-anchored it mid-count). The live state now carries staged_at and the + countdown anchors there — see Live State & the Race + Clock.
      • +
      • Protocol client: the resume cursor never advanced (a reconnect replayed + the entire backlog through the UI); an unhandled Delta envelope would have frozen the view + silently — both now converge via the re-snapshot path. Contract pins added for + GET /time (the clock-skew keystone), StartProcedure's tagged + shape, the SignalTrace projection, and the null-competitor audit row.
      • +
      + +

      4. Round 2 — the marshaling deep dive (2026-07-04, evening)

      +

      + A second adversarial pass focused exclusively on marshaling, per the RD's field report + (a removed not-a-full-lap crossing kept being re-proposed by threshold re-detection): + two review agents over the fresh work plus a seeded combo fuzzer — + random-but-reproducible sequences of every marshaling command (void / restore / adjust / + split / insert / throw-out / penalties / protests / finalize / revert / restart / + discard), interleaved with hostile targets (other heats' refs, abandoned-run refs, + out-of-range offsets, degenerate times) and a mid-race phase marshaling finished heats + WHILE another raced — with projection invariants checked after every single op. +

      +

      4.1 The shared removal record (the field report's root cause)

      +
        +
      • The lap list now carries each competitor's RD-voided passes + (CompetitorLaps::voided — raw instant + the pass's offset + the standing + void's offset), folded from the same corrected-passes fold that drops them from the laps. + Re-detection suppresses any unmatched crossing within tolerance of a + voided instant — never re-proposed, never inserted (see + D25).
      • +
      • Review caught the first cut applying suppression BEFORE diff matching — a voided + instant could steal a surviving pass's match and a commit would then void the lap the RD + kept, and a re-added lap fought the record forever. Suppression now runs strictly on + unmatched crossings.
      • +
      • Review also caught the record carrying a re-timed instant (the trace only knows raw + instants) and the projection's void-the-void resolution silently no-opping at chain depth + 3 — both fixed (the chain now walks with parity, so remove/restore/re-remove behaves).
      • +
      • Restore is a first-class path: the struck "removed pass" rows carry + a Restore button (void-the-void — previously unreachable through the command layer, so a + misclicked Remove was permanent), and the removal record re-opens on restore.
      • +
      +

      4.2 Command-layer hardening (the fuzz + sequence findings)

      +
        +
      • Tag-aware Final lock: heat_of_offset resolved + bridge-stamped passes positionally while scoring routed them by tag — a ruling on a late + tagged pass could bypass (or spuriously hit) the official-result lock. It now resolves + by tag, agreeing with the window fold.
      • +
      • Run-scoped rulings: a ruling targeting an abandoned run's pass (a + stale marshaling screen after Restart/Discard) was accepted, appended — and silently + inert in every view. Now rejected with "belongs to an ABANDONED run". Protests and + heat-voids additionally require the heat to HAVE a current run (a pre-run filing gated + Finalize while being invisible to every run-windowed view — an RD wedge).
      • +
      • Effectively-once rulings extended: stacked duplicate throw-outs and + DQs made "reverse the ruling" a silent no-op (the copy kept applying) — both now follow + the single-effective-record rule VoidHeat/ResolveProtest already had. Voiding a pass + whose lap carries a standing throw-out is rejected (the dangling ruling would silently + re-count the merged lap).
      • +
      • Degenerate instants rejected: an inserted/re-timed crossing must be + positive and within 24h of the source clock (a typo'd at: 0 became the + heat's earliest pass and collapsed the whole Timed window — every pilot scored zero), and + must not collide exactly with an existing pass of the same competitor (fuzz-caught: a + same-instant adjust folded a physically impossible zero-duration lap + that would win best-lap).
      • +
      • Splits now resolve their source recursively — splitting a + split-synthetic pass used to ack ok, log an audit entry, and change nothing.
      • +
      • Run-aware driver rechecks: the runtime clocks' fire-time recheck + verified the heat's STATE but not its RUN — a stale clock surviving a quick + ForceEnd + Restart could fire into the re-run. Each driver now captures a spawn + watermark (the heat's latest transition offset) and stands down if anything moved.
      • +
      +

      4.3 Console consolidation (the RD's requested redesign)

      +
        +
      • Tune detection is purely the enter/exit levels; the lap-times box is the + live detection readout while actively tuning — and only on explicit intent (a + drag or typed level; keyed per heat+pilot so a reused node seat can't carry stale intent + across heats).
      • +
      • Corrections live ON the lap rows: per-row Remove, an inline editor (Split / Edit + time / Insert after / Throw out) under the selected lap, inline Add-lap per competitor + (including pilots with zero laps), removal-record rows interleaved chronologically with + Restore. The separate correction and Add-a-lap panels are gone. Shared time inputs reset + on selection/pilot changes (a value typed for one lap can no longer pre-arm another's + editor).
      • +
      • The RSSI graph zooms (wheel at cursor, +/−/Fit, drag-to-pan) with + the downsampling budget following the visible window; pointer capture is deferred until + a real drag so lap-marker clicks keep working while zoomed.
      • +
      +

      4.4 Fuzz verdict

      +

      + Multiple seeded runs of 230–300 ops each: every hostile op correctly rejected with a + typed error (never a 5xx, never a wedge), every projection parseable and internally + consistent after every op (ordered positive-duration laps, dense numbering, voided refs + never doubling as lap boundaries, deterministic refetch), and the mid-race phase — + dozens of marshaling ops against finished heats while a third heat raced — lost + zero laps on the live heat (the pass-tag attribution holding under + fire). One non-reproducible determinism flake led to the run-aware driver-recheck + hardening above. +

      + +

      5. Deferred — recorded, deliberately not built

      +

      + Closed by the release-polish batch (2026-07-04, late): the Final freeze + (a delayed catch-up pass can no longer shift an official result), + version-skew rendering (unknown timer kinds/metrics render labeled instead of a crash / + a DNF-reading dash), the start-tone round-trip (editing any round silently erased + it), orphan bridge/presence tasks (they exit when their event is deleted), and the + presence-batch duplicate bindings. What follows is what genuinely remains. +

      +
        +
      • Failover completeness (dual-timer): crossings that land between the + primary's real death and the gate flip are dropped (a standby's passes are discarded, never + buffered/replayed), and a hung-but-open socket defers detection until the ~5s idle probe. + Real but rare (multi-timer setups), and buffering needs a design of its own — replay + semantics, dedup against the primary's already-appended passes. The cheap status-stomp fix + (§2) did land.
      • + + + + +
      + + +
      + + diff --git a/docs/roadmap.html b/docs/roadmap.html index da86dd6..022198b 100644 --- a/docs/roadmap.html +++ b/docs/roadmap.html @@ -54,13 +54,35 @@

      1. How the plan is shaped

      testing strategy is a phase input, not an afterthought.

      +
      +

      + 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. +

      +
      +

      2. The phases

       flowchart LR
         v01["v0.1<br/>Walking skeleton"] --> v02["v0.2<br/>First adapters"]
         v02 --> v03["v0.3<br/>Race engine"]
      -  v03 --> v04["v0.4<br/>Director + RD console"]
      +  v03 --> v04["v0.4<br/>Direct a single race (RD console)"]
         v04 --> v05["v0.5<br/>Racer/spectator PWA"]
         v05 --> v06["v0.6<br/>Streaming / broadcast"]
         v06 --> v07["v0.7<br/>Cloud"]
      @@ -74,16 +96,28 @@ 

      2. The phases

      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 format generators are all in + the tree — since the primitives-first carve-out (2026-06-30, PR #327) the registry ships + three primitive generators (Practice / Time Trials / Head-to-Head); the + tournament structures are preserved on tournaments-snapshot, rebuilt post-v1. + 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).

      @@ -108,18 +142,80 @@

      v0.3 — Race engine

      Goal: turn passes + a format into a run race. (Race Engine)

      • The logged heat-loop state machine; scoring/lap-derivation + win conditions; marshaling adjudications.
      • -
      • The unified format/generator interface + first formats (timed qualifying, single-elim; ZippyQ as the honesty check); advancement; multi-class + engine-side frequency allocation.
      • +
      • The unified format/generator interface + first formats (Time Trials, single-elim; ZippyQ built then shelved as the dynamic-generator honesty check — D10); advancement; multi-class + engine-side frequency allocation.

      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): +

      +
      +

      + Built so far in v0.4. The earlier slices landed the + design-system foundation, the Event aggregate + workspace + (always-present non-persistent Practice; event-picker; you cannot act outside an event), and + three app-level registries with “configure once, select per event” + semantics: the Pilot directory (#74), the Class registry + (nine locked built-ins + Custom), and the Timer registry (Mock + RotorHazard, + persistent connect-on-select, drop detection / reconnect, primary/alternate failover; #73). + The console home-hub IA (Pilots / Classes / Events / Timers pages, hash + routing, monogram + wordmark) and per-event persistence via the sidecar meta + table (#115) are in. A hands-on review then reshaped the remaining v0.4 work into the + race-running redesign below. +

      +
      +

      + Race-running redesign — the remaining v0.4 slices. The event workspace becomes a + sequence of editable stage-pages (Classes · Roster · Rounds+Heats · Live control · + Marshaling · Results — no “Setup” tab), and the work is staged to wire the existing + v0.3 engine per-event: +

        -
      • 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 — Log tags. Tag the log (notably HeatScheduled) with + class / round / per-pilot frequencies; promote ClassId / RoundId into + the events crate (Architecture §4).
      • +
      • Slice 1 — Roster = membership = presence. Per-class roster membership; + IRL = RD adds pilots + binds node→pilot, Sim = auto-membership from CompetitorSeen + (auto-add + auto-bind name matches).
      • +
      • Slice 2 — Rounds. Event-level, class-tagged, dynamically-added rounds + (practice / quali as-you-go).
      • +
      • Slice 3 — Engine + heats. Wire the v0.3 heat-loop FSM, scoring, and the six + generators per-event; heat-filling = manual + format-generated.
      • +
      • Slice 4 — Channels. Timer-defined channels (standard catalog + adapter + fixed/flexible capability + custom raw-MHz), node-count cap, engine first-fit + manual assign, + dynamic node tuning at race time (#117, folded into timers).
      • +
      • Slice 5 — Bracket carry. Advance-to-brackets generates the full bracket from + the top-N quali ranking, editable — reusing run_event's quali→bracket carry (#84).
      • +
      • Slice 6 — Per-class standings. Results and standings derived per class.
      • +
      • Slice 7 — Setup wizard. A guided first-pass over the stage-pages, + built last.
      -

      Done when: a complete event runs live from the RD console against real/dockerized RH, and the single binary launches on all three OSes.

      +

      + Deferred out of v0.4: ZippyQ shelved from the active format + set (D10, #218) and PWA self-register; the tournament + structures (round-robin, single/double elim, multi-main, chase-the-ace, bracket + builders + viz) carved to tournaments-snapshot for the primitives-first release + (PR #327), rebuilt post-v1; channel + management as a standalone feature (#117 is folded into the Timer registry instead); + IMD-aware channel assignment — scoring a heat's channels by third-order intermod + gap and auto-picking the cleanest set (#209), a race-quality refinement on top of the v0.4 channel + model that cuts interference-related video dropouts; seasons (#129) stay local-first for now; + multi-cloud (#132) is later. Packaging follows alongside: a Tauri shell + + single-binary build for Windows/Linux/macOS (#57/#58). +

      +

      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)

      @@ -139,7 +235,7 @@

      v0.6 — Streaming / broadcast

      v0.7 — Cloud

      Goal: off-LAN reach and longevity. (Cloud & Season)

        -
      • One-way resumable push (Director → Cloud); Postgres warehouse via the same server crate + storage trait.
      • +
      • One-way resumable push (Director → Cloud); Postgres warehouse via the same server crate + storage trait — the Cloud realization of the event-as-container model (the events + event_log schema in Architecture §4).
      • Accounts + free-account-gated reads; the live relay (same PWA off-LAN) + web push; long-term portal basics (career, standings); the season model.

      Done when: a Director with internet relays a live event to off-LAN viewers, a season aggregates across events, and a self-hosted Cloud deploy is documented.

      @@ -152,6 +248,28 @@

      v0.8 — Integrations

      Done when: roster import + results push work against a chapter, and the open-data API + widgets serve.

      +

      RH integration — a required GridFPV RotorHazard plugin

      +

      Goal: make the RotorHazard integration plugin-based, for native control and live dense signal. (Timer Adapters, D16)

      +
        +
      • A required GridFPV RotorHazard plugin running inside the RH server + with RHAPI access (node manager / race events / config). It supersedes the + socket-API workarounds: live dense RSSI (no save-then-pull), clean + start/stop bypassing the RMS staging, per-node passes without the + seating dance, and threshold/calibration access — which unblocks the + draggable-threshold "recalculate" re-detection marshaling feature (#3). It + reuses RH's entire proven stack (node firmware + RMS + the detection moat): + no RMS replacement, no firmware re-hosting.
      • +
      +

      + This replaces the dropped "own-the-stack" native timer-server (D15, + superseded): the plugin delivers the same payoff far more cheaply, so the RH integration is no + longer a post-1.0 firmware project — it is the integration path. + "Required" is a guided one-step install (Grid detects a missing plugin and + offers to install it), not a hard-fail, keeping the "try it fast" feel. The plugin becomes the + primary RH integration; the full plugin design doc + sliced build plan are + forthcoming (D16). +

      +

      3. Cross-cutting workstreams

      These run across every phase rather than living in one:

      diff --git a/docs/rotorhazard-plugin.html b/docs/rotorhazard-plugin.html new file mode 100644 index 0000000..e750e33 --- /dev/null +++ b/docs/rotorhazard-plugin.html @@ -0,0 +1,342 @@ + + + + + + GridFPV — RotorHazard Plugin + + + +
      +
      +

      GridFPV

      +

      RotorHazard Plugin

      +

      Internal design doc · the required in-process RH integration + its sliced build plan

      +
      + +

      + The decided primary RotorHazard integration (D16): a small + GridFPV plugin that runs inside the RH server with RHAPI access, + replacing the socket-API ceiling. It makes the socket workarounds native — live dense + RSSI (no save-then-pull), clean start/stop bypassing RH's RMS staging, + per-node passes without the seating dance, and threshold access + that unblocks the draggable-threshold recalculate marshaling feature + (#3). It reuses RH's entire proven stack — node firmware, RMS, + and the detection moat — at a fraction of the dropped own-the-stack cost. +

      + +
      +

      + This doc owns the plugin architecture, the Grid↔plugin + protocol, the required-with-guided-install onboarding, and the + sliced build plan. It is downstream of Timer + Adapters (the canonical event model — unchanged; the plugin is a richer RH adapter behind + the same vocabulary) and D16 (the direction). Where this + doc and Timer Adapters disagree on the event model, Timer Adapters wins. +

      +
      + +

      1. Principles

      +
      +

      Grid owns timing; the plugin is start/stop/get-data. The plugin + never decides when a race begins or ends — Grid does, on Grid's clock. The plugin + exposes a thin executor surface (seat, start, stop, read signal, set threshold) and reports + observations. This is the same adapter philosophy as the socket path; only the transport and + fidelity change.

      +
      +
      +

      Same canonical events, richer source. The plugin still produces + Pass, CompetitorSeen, and SessionStarted/Ended — the + canonical model is untouched. What changes is that signal + context arrives live and dense instead of via a post-race pull, and the control surface + is native instead of worked-around. The Grid-side RH adapter gains a new transport + (the plugin channel), not a new event model.

      +
      +
      +

      Required means guided, not gated. Grid detects a missing or + incompatible plugin and offers a one-step install — it does not hard-fail with a + cryptic error. Every serious RMS (FPVTrackside, LiveTime) already requires its own timing + plugin, so this is the normal shape, not friction (D16).

      +
      +
      +

      Raw handles on the wire; friendly names resolved in Grid. The + protocol carries raw handles — node-seat index, RH pilot id, frequency in MHz. Grid + resolves callsigns and channel labels through the shared resolvers (CLAUDE.md). The plugin never + invents a display name.

      +
      + +

      2. What the plugin taps (verified RHAPI surface)

      +

      + The floor is RHAPI v1.3 (RotorHazard v4.3.0+); current is v1.4 (RH v4.4.0). + The in-process mechanics were spiked against the harness image + (cruwaller/rotorhazard:latest = RH 4.0.0-beta.4) and the API surface verified + against the v4.3.0 and v4.4.0 source. A plugin is a directory + under RH's plugins/ with an __init__.py exposing + initialize(rhapi) and a manifest.json declaring + required_rhapi_version (gated by the loader at 1.3+); RH imports it and hands over the + rhapi object. From there: +

      +
      +
      Event hooks — rhapi.events.on(evt, handler)
      +
      The race lifecycle and pass stream arrive as events, not snapshot diffs: + RACE_STAGE, RACE_START, RACE_FINISH, RACE_STOP, + RACE_LAP_RECORDED (the pass atom), CROSSING_ENTER/CROSSING_EXIT, + ENTER_AT_LEVEL_SET/EXIT_AT_LEVEL_SET, FREQUENCY_SET, + HEAT_SET.
      +
      Live dense signal — rhapi.interface.seats
      +
      The node list. Each Node carries current_rssi, + node_peak_rssi, pass_peak_rssi, the nadir variants, + enter_at_level, exit_at_level, frequency, index, + and the dense per-pass trace history_values[] / history_times[] — read + live, in-process. This is the save-then-pull killer.
      +
      Race state — rhapi.race
      +
      status, slots, pilots, laps, + start_time_internal — read for attribution and to anchor the clock.
      +
      Clean control — rhapi.race.stage(args) / rhapi.race.stop(doSave)
      +
      Real, sanctioned methods at 1.3+ (in RHAPI 1.0 they were pass stubs + monkeypatched at runtime — the floor avoids that). This is the clean start/stop, no socket + stage_race / alter_race_format dance.
      +
      Calibration write — rhapi.db.frequencyset_alter(enter_ats=, exit_ats=)
      +
      The sanctioned, persisted threshold write-path (per-channel enter/exit levels on the frequency + set). Note: rhapi.interface stays read-only even at 1.4 + (seats + add only), so an immediate, live threshold change uses + the internal interface setter via RaceContext (semi-private, stable). For + #3 recalc we mostly need the dense trace (below), not a live + setter — see §3.
      +
      Seating — rhapi.db + rhapi.race.slots
      +
      Read/assign node-seat ↔ pilot in-process, no add_pilot/alter_heat + socket round-trips and no new-pilot-id polling.
      +
      The channel back to Grid — rhapi.ui.socket_listen / socket_broadcast
      +
      At the 1.3 floor the plugin registers custom socket.io handlers + (socket_listen(message, handler)) and pushes (socket_broadcast / + socket_send) under a gridfpv_* event namespace — on RH's + existing socket.io server. The Grid adapter already holds a socket.io connection to RH, so + the plugin channel rides that same connection and port — no hosted blueprint, no + second transport.
      +
      + +

      3. The Grid↔plugin protocol

      +

      + A versioned gridfpv_* event namespace the plugin registers on RH's existing socket.io + server (the same host/port RH already serves, default :5000, and the same connection + the Grid adapter already holds): socket.io acks carry request/response control, + broadcasts carry the live push (passes, signal, lifecycle). JSON encoding, + versioned in the gridfpv_hello handshake. The two directions: +

      +
      +
      Control · Grid → plugin (gridfpv_* with ack)
      +
      gridfpv_hello (handshake: plugin version, RHAPI version, declared capabilities, + node count) · gridfpv_clock_sync (NTP-style exchange — RH's monotonic clock demands + it) · gridfpv_seat (assign node → {raw RH pilot id, callsign, + frequency/channel}) · gridfpv_start / gridfpv_stop (Grid's go/finish — + maps to race.stage() / race.stop()) · gridfpv_calibrate + (write per-channel enter/exit via frequencyset_alter).
      +
      Data · plugin → Grid (gridfpv_* broadcast)
      +
      gridfpv_session (stage/start/finish/stop edges) · gridfpv_competitor_seen + (a node seat went active) · gridfpv_pass (the atom: node seat, source timestamp, + lap#/sequence, peak RSSI) · gridfpv_signal (live dense RSSI per node — + current_rssi heartbeat plus the per-pass history_values window) · + gridfpv_status (liveness + node/threshold snapshot).
      +
      +
      +

      + The #3 recalc is a re-detection, not a hardware command. RH exposes no "re-run + detection at new thresholds" call, so the draggable-threshold recalculate is a computation + over the stored dense trace (the history_values Grid captured in S2): replay RH's + enter/exit hysteresis at the marshal's chosen levels and propose the resulting laps. It + needs the trace + a faithful copy of the detection rule — not a live setter. Committing a new + calibration optionally writes it back via gridfpv_calibrate. +

      +
      +

      + Each message maps 1:1 onto a canonical event: pass → Pass + (with signal context attached), competitor-seen → CompetitorSeen, + session → SessionStarted/Ended. The Grid-side adapter + (crates/adapters/src/rotorhazard) keeps its translator and gains a plugin transport + alongside the socket one — the same dedup-on-sequence and reconnect-resume guarantees apply + (Timer Adapters §4–5). +

      + +
      +flowchart LR
      +  subgraph rh["RotorHazard server · :5000 · socket.io"]
      +    nm["Node manager<br/>dense RSSI · thresholds"]
      +    rms["RMS · race lifecycle"]
      +    subgraph plug["GridFPV plugin (in-process, RHAPI 1.3+)"]
      +      hooks["events.on / interface.seats<br/>race.stage·stop · frequencyset_alter"]
      +      ch["socket_listen / socket_broadcast<br/>gridfpv_* namespace"]
      +    end
      +    nm --> hooks
      +    rms --> hooks
      +    hooks --> ch
      +  end
      +  subgraph grid["GridFPV Director"]
      +    adp["RH adapter<br/>plugin transport"]
      +    canon{{"Canonical events<br/>Pass · signal · lifecycle"}}
      +  end
      +  ch -->|"broadcast: pass · signal · session"| adp
      +  adp -->|"ack: seat · start · stop · calibrate"| ch
      +  adp --> canon
      +
      +  classDef sot fill:#eaf7e1,stroke:#43b301,stroke-width:2px;
      +  class canon sot;
      +    
      + +

      4. What it supersedes

      +

      Every socket scar from the current path has a native plugin equivalent — the workaround code retires:

      + + + + + + + + + + + + +
      Socket workaround (today)Native plugin pathRetires in
      save-then-pull dense RSSI (save_lapsload_dataget_pilotrace)interface.seats[i].history_values read liveS2
      alter_race_format staging-zero hackplugin owns the format + calls race.stage()S3
      add_pilot/alter_heat seating dancerhapi.db + race.slots in-processS3
      un-zeroable ~0.9 s prestage (RACE_START_DELAY_EXTRA_SECS)plugin zeroes it via rhapi.config at load — shipped independently of the control rewrite, so stage_race reaches RACING immediatelydone
      per-pass RSSI only via node_datafull current_rssi/pass_peak_rssi/levels every heartbeatS2
      no signal write-back (blocks #3 recalc)re-detection over the stored dense trace + frequencyset_alter calibrateS4
      + +

      5. Onboarding — required, with a guided install

      +

      + On connecting its socket.io link to a configured RH timer, Grid emits gridfpv_hello + and awaits the ack (with a short timeout). Three outcomes: +

      +
      +
      Present & compatible
      +
      The ack returns the plugin version, RHAPI version, and capabilities. Grid prefers the plugin + transport and surfaces a healthy TimerStatus. (The friendly timer name is shown, never + the URL — CLAUDE.md.)
      +
      Missing
      +
      The handshake times out (no gridfpv_* handler registered). Grid shows a + guided one-step install: the plugin is a small folder dropped into RH's + plugins/ directory followed by an RH restart. Grid offers the versioned plugin bundle + to download plus the copy-and-restart steps; where Grid runs on the same host as RH it can automate + the copy. This is a prompt, not a hard error. (An RH older than v4.3.0 fails the same way + and is told to update RH first.)
      +
      Present but incompatible
      +
      The handshake version is outside Grid's supported range. Grid offers the matching plugin + version and explains the mismatch in plain language.
      +
      +
      +

      + Transition: while the plugin rolls out, the existing socket transport stays as + a fallback behind the same adapter — a stock RH without the plugin still works at + reduced fidelity (no live dense signal, no recalc), and Grid nudges toward the install. Once the + plugin is the assumed path, the socket-workaround code (the save-then-pull, the + alter_race_format hack, the seating dance) is deleted (S3). +

      +
      + +

      6. Version & compatibility

      +
      +

      Floor on RHAPI 1.3 (RH v4.3.0+); use the new features. The plugin + requires RH v4.3.0+ (RHAPI 1.3, verified to carry socket_listen/ + socket_broadcast, real race.stage()/stop(), frequencyset_alter, + and manifest.json + required_rhapi_version gating). Current RH is v4.4.0 + (RHAPI 1.4). The manifest.json declares required_rhapi_version 1.3 so + RH's own loader refuses to load us into a too-old server, and Grid surfaces "update RH to v4.3.0+" + as part of the guided install. We are comfortable making users update RH — every serious RMS + requires a current timer (D16).

      +
      +
      +

      + Harness reality: cruwaller/rotorhazard:latest is stale at + 4.0.0-beta.4 (only :latest is published) — below our floor. So the + harness bump (S0) is not a tag swap: we build a mock-node image ourselves from the + RotorHazard v4.4.0 source. The MockInterface reads RSSI/history from + a mock_data CSV, so the emulated-signal slice (S2) is testable against the mock once + the image is current. +

      +
      + +

      7. Build plan — five slices

      +

      + Each slice is a vertical cut, shippable before the next, and gated on + cargo xtask ci + the full frontend gate (backend binding changes run the frontend + gates too). The Grid↔plugin protocol is additive to the existing adapter, so each slice + leaves the socket fallback intact until S3 deletes it. +

      +
      +
      Slice 0 — Current-RH plugin dev harness
      +
      Build a docker/rotorhazard/ image from RH v4.4.0 with + MockInterface + an emulated-signal CSV, and extend cargo xtask live / + rh-mock to mount the GridFPV plugin into the container's plugins/ dir. + Unblocks testing every later slice against current RH. (A local + rh-plugin-dev-env image already exists — fold it in.)
      +
      Slice 1 — Plugin skeleton + handshake + guided-install UX
      +
      plugins/gridfpv/__init__.py (+ manifest.json, required_rhapi_version 1.3) + registering gridfpv_hello via socket_listen (version/capabilities). Grid-side: the handshake probe, the + required-with-guided-install flow (§5), and the adapter learning to prefer the + plugin transport (socket fallback retained). Ships: Grid recognizes a plugin-equipped RH and walks + a missing one through install.
      +
      Slice 2 — Live dense RSSI → replace the save-then-pull
      +
      Plugin broadcasts per-node signal over the gridfpv_signal channel (current_rssi heartbeat + + per-pass history_values window); Grid consumes it as the pass's signal context and the + heat's trace. Retires the path-2 save_lapsload_dataget_pilotrace + pull. Validated against the emulated-signal harness. Headline marshaling-fidelity win.
      +
      Slice 3 — Clean start/stop + per-node passes → retire the socket hacks
      +
      Plugin executes seat (via rhapi.db), start + (race.stage()), stop (race.stop()), and emits passes from + RACE_LAP_RECORDED attributed by node seat. Grid still owns the timing. Deletes the + alter_race_format staging-zero, the add_pilot/alter_heat + dance, and the prestage fight. The socket fallback's workaround code is removed here.
      +
      Slice 4 — Draggable-threshold recalculate (#3)
      +
      A recalculate that replays detection over the stored dense trace at marshal-chosen + enter/exit levels and proposes lap changes (Grid/marshal commits; RH truth is not mutated + under the RD), plus an optional gridfpv_calibrate write-back via + frequencyset_alter. The marshaling graph gets draggable thresholds over the captured + trace. The payoff feature owning-the-stack was for — delivered on RH's detection.
      +
      + +
      +flowchart LR
      +  S0["Slice 0<br/>RH 4.4 dev harness"]
      +  S1["Slice 1<br/>skeleton + handshake<br/>+ install UX"]
      +  S2["Slice 2<br/>live dense RSSI"]
      +  S3["Slice 3<br/>clean start/stop<br/>+ per-node passes"]
      +  S4["Slice 4<br/>threshold recalc (#3)"]
      +  S0 --> S1 --> S2 --> S3 --> S4
      +    
      + +

      8. Risks & open decisions

      +
        +
      1. Resolved — RH-only, no cross-RMS abstraction. RotorHazard is the + only FPV timing system with a user-installable plugin API (FPVTrackside is compile-in C#; LiveTime + and LapRF are fixed protocols; MultiGP is a cloud REST API whose RH integration is itself + an RH plugin). Any future non-RH timer is a protocol adapter, not a plugin host, and the + canonical event model already provides that seam — so we build concretely for RH and do not design + a speculative cross-RMS plugin protocol.
      2. +
      3. Pass attribution across the pilot gate — verify in S3 that + RACE_LAP_RECORDED attributes by node seat regardless of RH's seated-pilot gate (so we + need no socket-era seating just to make passes count), or seat via rhapi.db if RH + still gates.
      4. +
      5. Recalc detection fidelity (load-bearing). The #3 recalculate replays RH's + enter/exit hysteresis over the stored trace — its credibility depends on that replay matching RH's + real detector. Either reimplement RH's crossing algorithm faithfully (and pin it to an RH version) + or run the recalc in the plugin against RH's own detection code. Decide in S4; verify a + recalc at RH's actual thresholds reproduces RH's actual laps on a captured trace.
      6. +
      7. Recalc must be deterministic and non-destructive — re-running detection over a + stored trace yields the same result every time and proposes (never silently rewrites) laps, per the + Marshaling commit/reversibility model.
      8. +
      9. Live-signal volume — history_values can be large; bound the streamed window and + decimate the heartbeat so the broadcast stays cheap on a Pi.
      10. +
      11. Install automation vs. instructions — same-host Grid can copy the plugin folder directly; + remote RH gets download + copy-and-restart steps. Pick the same-host automation as the happy path.
      12. +
      + + +
      + + + + diff --git a/docs/testing-strategy.html b/docs/testing-strategy.html index 4ded3f3..8f82b17 100644 --- a/docs/testing-strategy.html +++ b/docs/testing-strategy.html @@ -309,7 +309,98 @@

      5.1 Emulated-signal races — driving RH's real pipeline

      canonical-log fixtures (§4).

      -

      6. Testing the format generators

      +

      5.2 The interactive mock-signal harness — cargo xtask rh-mock

      +

      + The same emulated-signal mechanism (§5.1) is also a runnable, interactive tool + for hands-on marshaling testing: drive a real dockerized RotorHazard with a controllable + mock signal, run a heat against a Director, and see the exact RSSI/signal values that get + captured. It is a thin xtask wrapper over the gridfpv-testkit + scenario library and a read-only fetch of the existing ?projection=signal route — it + modifies no adapter or projection code. +

      +

      Three subcommands:

      + + + + + + + +
      CommandDoes
      cargo xtask rh-mock listprint the scenario menu
      cargo xtask rh-mock feed [scenario] [--port P] [--tick T]generate the chosen mock signal, feed it to a fresh disposable RotorHazard, and stay running while you point a Director at the timer and run a heat (needs Docker)
      cargo xtask rh-mock dump <director-url> <event> <heat>after the heat, pretty-print the captured per-competitor RSSI samples, enter/exit thresholds, a sparkline, and the recorded lap list — the raw "what values flowed" view (the marshaling graph renders it visually)
      +

      + The scenario menu reuses the testkit generators, so the same situations the e2e suite asserts on + can be driven by hand: clean (metronomic laps), missed-lap (a skipped + crossing), false-pass (a fast double-trigger to void), noisy (marginal + RSSI at the edge of detection), strong-vs-weak (two nodes to compare signal context), + dnf (one node drops out), and pack (simultaneous passes). +

      +

      A typical marshaling-testing session:

      +
      +# 1. Spin up a real RH fed a "missed lap" signal (parks until Ctrl-C):
      +cargo xtask rh-mock feed missed-lap
      +
      +# 2. In another shell, start a Director BUILT WITH THE LIVE FEATURE (the RH
      +#    connector is `live`-gated — a default build never connects to RH):
      +cargo run -p gridfpv-app --features live
      +
      +# 3. In its console: add a RotorHazard timer pointing at the printed URL
      +#    (e.g. http://localhost:5055), select it, and MAKE THE EVENT ACTIVE — the
      +#    Director only connects the ACTIVE event's selected RH timers (the timer
      +#    should read `Connected`). Build a heat seating a pilot on node-0 (the seat
      +#    the scenario actually feeds — a pilot on an unfed node gets no crossings),
      +#    then Stage -> Start it (a normal heat: Start is what drives RH into RACING;
      +#    a heat left only Staged never races, so it records no laps).
      +
      +# 4. After the heat, dump the captured signal values for that heat:
      +cargo xtask rh-mock dump http://localhost:3000 practice q-1
      +
      +

      Stage and Start — and seat a pilot on a fed node. + The two ways a mock-feed heat silently records no laps: (a) the heat never + reaches RACING — a RotorHazard timer only replays its mock signal into passes while it is in a + racing state, so a heat that is only Staged (never Started), or one whose staging is + aborted, produces nothing; and (b) the heat's pilot is on a node the scenario does not feed, so + crossings arrive for an empty seat and attribute to nobody. The Director drives RH + Staged → RACING on Start; the + director_connects_rotorhazard_on_selection_and_keeps_it_connected_through_a_heat + live test (crates/app/tests/rh_connect_live.rs) is the regression guard that real + passes flow over the staged-then-started connection.

      +
      +

      + Like the rest of §5, this is a local class: feed needs Docker, so + it never runs in CI; the scenario menu + JSON-pointer extraction are unit-tested in the core + suite (no Docker, no server). See xtask/src/rh_mock.rs. +

      + +

      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 +429,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 +439,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 +483,7 @@

      7. Test levels

      contract -.-> e2e -

      8. Open decisions

      +

      9. Open decisions

      1. Resolved — Fixture format & storage. Fixtures are stored as JSON and checked in alongside the code: per-adapter source/canonical fixtures diff --git a/docs/timer-adapters.html b/docs/timer-adapters.html index bd36055..e172608 100644 --- a/docs/timer-adapters.html +++ b/docs/timer-adapters.html @@ -202,14 +202,34 @@

        5. The adapter interface & capabilities

        proceeds. A source that drops mid-race reconnects and resumes appending; deduplication relies on source sequence numbers / timestamps so a reconnect can't double-count a pass.

        +
        +

        + Channel capability is part of what an adapter declares. Beyond the yes/no + “frequency / channel mgmt” row, an adapter that manages channels declares + how: a fixed capability (only a built-in allowed set of channels) or a + flexible one (the standard FPV channel catalog plus arbitrary custom raw-MHz, + as RotorHazard allows), together with its node/slot count. The standard + catalog is built-in; what a given timer can actually tune to lives in that timer's + config. The race engine assigns channels from this declared set (first-fit + RD override), + capped by the node count, and the adapter dynamically tunes its nodes to the + assignment at race time (Race Engine §5, + Setup). Sim sources declare no channel capability. As + built (v0.4), this is the Timer-registry model (#73/#117): channels are configured on the timer, + not on the event — and crucially a timer's available channels can exceed its node + count. The full model (channels ≠ nodes, the standard catalog, Fixed/Flexible, static + vs per-heat assignment) has its own deep dive: Channel Model. +

        +

        6. Identity binding

        Each source speaks in its own local references — a node seat, a sim player name, a transponder id. Binding those to a GridFPV pilot is a registration action (Architecture §9), not something the adapter decides; the adapter only reports the local - refs it sees (CompetitorSeen), which drives auto-presence ("everyone in - the room at 8 PM gets bracketed") and gives the RD the list to bind against. + refs it sees (CompetitorSeen), which for sim drives auto-membership + — auto-adding seen players to the class roster (membership = presence) and auto-binding name + matches ("everyone in the room at 8 PM gets bracketed") — and gives the RD the list to bind + against (Setup).

        7. Velocidrone — the first adapter

        @@ -253,6 +273,45 @@

        7. Velocidrone — the first adapter

        8. Other sources (sketch)

        +
        +

        + As built (v0.4) — the timer model & connection lifecycle. A timer in the + registry is one of two kinds: Mock (a synthetic source — laps and + lap_ms drive fake passes; seeded with the 8-seat Raceband grid) or + RotorHazard { url } (a real RH server base URL). A RotorHazard timer + connects when it is selected for the active event and stays connected (it does + not connect only while a heat runs); a persistent driver maintains the link and + reconnects with backoff, surfacing a TimerStatus of + Ready / Configured / Connecting / Connected / Disconnected / Error. RH support is + gated behind the Director's --features live build. When a heat enters + Running, the bridge arms the heat (remapping node seats onto the lineup) and tunes the + nodes; stage_race() stops any prior race, discards old laps, stages, and waits up + to ~15 s for RH to reach racing; leaving Running disarms the race but + keeps the connection alive. +

        +
        +
        +

        + Direction (2026-06-26) — the RH integration becomes a required GridFPV plugin + (D16). The socket-only path described + here works against stock RH but is limiting: the dense per-tick signal needs a + save-then-pull, staging is zeroed with an alter_race_format + hack, seating needs an add_pilot/alter_heat dance, and the ~0.9 s + prestage can't be zeroed. The decided direction is a required GridFPV RotorHazard + plugin running inside the RH server with RHAPI access + (node manager / race events / config). The plugin makes those workarounds native — live + dense RSSI (no save-then-pull), clean start/stop bypassing the RMS + staging, per-node passes without the seating dance, and + threshold/calibration access (unblocking the re-detection "recalculate" + marshaling feature). It reuses RH's proven stack (node firmware + RMS + detection); GridFPV + owns start/stop timing while RH owns start/stop/get-data. "Required" is a guided + one-step install, not a hard-fail. The plugin supersedes the socket + workarounds and becomes the primary RH integration; its full design + + build plan are forthcoming separate work (this records the direction). It also + replaces the dropped own-the-stack timer-server (D15, + superseded). +

        +
        RotorHazard
        Socket.IO / RHAPI; the full-signal case, with calibration and signal-based recovery. @@ -271,7 +330,11 @@

        8. Other sources (sketch)

        the session start/end edges, and the per-node lap_number is carried as the pass sequence and anchors dedup. Built in parallel with Velocidrone as the first real-world target; the dockerized RH both feeds recorded fixtures and is allowed for live - integration tests (a local class — Docker required). The guardrail bans + integration tests (a local class — Docker required). The harness runs + cruwaller/rotorhazard with 8 mock nodes on port 5000, + attached via the timer's URL; the test driver emits stage_race over Socket.IO and + injects passes with simulate_lap {node}, bypassing signal detection (see + docker/rotorhazard/). The guardrail bans depending on a live, shared/production backend (concretely the Velocidrone game servers, which stay fixtures-only) — not all live timers.
        LapRF
        @@ -305,11 +368,13 @@

        9. Open decisions

        idempotent, keyed on (source, sequence#) — or (source, local-ref, source-timestamp) where no sequence counter exists. On reconnect the adapter buffers and de-dups against those keys, so a resume can't double-count or lose passes.
      2. -
      3. Resolved — Frequency / channel management ownership. +
      4. Resolved — Frequency / channel management ownership & model. The race engine allocates channels — it knows the heat/field and avoids conflicts — while the adapter advertises the hardware's channel - capabilities/constraints and applies the tuning. Shared with the Race - Engine; IRL sources only.
      5. + capability (fixed allowed-set or flexible standard-catalog + custom raw-MHz) + and node count, and applies the tuning dynamically at race time. Channels are + defined on the timer (the Timer registry, #73/#117), not an event-level pool. + Shared with the Race Engine; IRL sources only.
      6. Resolved — Velocidrone WebSocket message schema. Reconstructed and implemented from two independent open-source consumers (the rh-velocidrone plugin and VelocidroneWebSocketConsumer); a raw diff --git a/docs/vision.html b/docs/vision.html index a44f4d8..4210319 100644 --- a/docs/vision.html +++ b/docs/vision.html @@ -49,7 +49,7 @@

        1. What GridFPV is

        to the engine.

        -

        The product vision rests on four pillars:

        +

        The product vision rests on five pillars:

        All-in-one & FOSS. One open, self-hostable app @@ -75,6 +75,13 @@

        1. What GridFPV is

        interface is a deliberate goal and a real differentiator — race directors and pilots should enjoy using it.

        +
        +

        Defensible results — accountability by design. Results are + not disposable. Because the truth is an append-only log and the system is a deterministic + fold over it, every call — a detected lap, a marshal's correction, a penalty, a lock — is a + recorded, attributable, reversible fact. This is foundational, not a marshaling sub-feature; + see §7.

        +

        2. The thesis

        @@ -210,6 +217,48 @@

        Table stakes we must match

        gap, in the open, is the work.

        +

        7. Defensible results — accountability by design

        +

        + Every other FPV timing system treats results as disposable — corrections overwrite data, + nothing records who changed what, and "official" is a verbal convention. GridFPV is the + opposite: because the event log is append-only and the whole + system is a deterministic fold over it, every action — a detected lap, a + marshal's correction, a penalty, a result being locked — is a recorded, attributable, + reversible fact. Marshaling is evidence-backed (the raw timer signal is + preserved as the ground truth you review against); results move through an explicit + provisional → official lifecycle with a protest window, role-gated so only + the RD commits while pilots see but cannot change the record. The payoff: results are + defensible — every call traces to evidence and to a person, nothing is + silently lost, disputes are settled by replaying the record. The architecture was chosen for + this; marshaling is where it becomes visible. +

        +

        + This theme is not a new mechanism bolted on — it is what the existing foundations already + buy us, named and made first-class: +

        +
        +

        Log-sourced & replay-deterministic is the enabler. The + append-only spine (Architecture §3) means corrections are + appended facts that reference the raw observations they adjudicate — never + destructive edits. The deterministic fold means current truth is always recomputable from + the record, so audit, provenance, reversibility, and recompute-after-a-ruling come natively. + Every marshaling action is a logged event with who/when/source, for free.

        +
        +
        +

        The auth tiers are the other enabler. The + protocol's control/read split (Protocol + §5) makes "only the RD commits, pilots see but cannot change the record" a real, enforced + boundary rather than a convention: the RD holds the privileged control path while the + read-only pilot tier is first-class. Defensibility needs both — an immutable record + and a role model over who may write to it.

        +
        +

        + The dedicated design is in Marshaling: timer-agnostic + lap-level corrections, signal-as-evidence where the timer provides a trace, and the + governance layer (provisional → official, audit trail, reversibility, roles, adjudication) + that no existing system has. +

        +